feat(clients): maker-checker for client edits (frontend)
- New "Pending Client Edits" screen under Checker Inbox & Tasks: server-driven pagination,
date range + Today/Yesterday/All presets, status filter (Pending/Approved/Rejected), search —
mirrors ChequebookRequestsComponent's architecture.
- Compare dialog diffs the maker's proposed values against a point-in-time "before" snapshot
(client-edit-diff.util.ts) captured when the edit page was opened, so only genuinely changed
fields show — works correctly for Pending, Approved, and Rejected edits alike, since it no
longer depends on comparing against the live (possibly already-updated) client record.
- edit-client.component.ts: General tab save now submits for approval instead of applying
immediately, shows a pending-approval banner, and blocks a second submission while one's
outstanding.
- error-handler.interceptor.ts: added the new /clients/{id}/edit/pending check to the existing
"expected 403/404, don't alert" allowlist (same pattern already used for client images/KYC).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -82,6 +82,43 @@ export class ClientsService {
|
||||
return this.http.delete(`/clients/${clientId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maker-checker for Client edits — stages the change in m_client_pending_edit instead of
|
||||
* applying it immediately; a different user must call approveClientEdit() before it takes
|
||||
* effect. See ClientEditApiResource on the backend.
|
||||
*/
|
||||
submitClientEdit(clientId: string, client: any) {
|
||||
return this.http.post(`/clients/${clientId}/edit`, client);
|
||||
}
|
||||
|
||||
getPendingClientEdit(clientId: string) {
|
||||
return this.http.get(`/clients/${clientId}/edit/pending`);
|
||||
}
|
||||
|
||||
getAllPendingClientEdits(
|
||||
status?: string,
|
||||
clientName?: string,
|
||||
fromDate?: string,
|
||||
toDate?: string,
|
||||
offset = 0,
|
||||
limit = 20
|
||||
): Observable<any> {
|
||||
let params = new HttpParams().set('offset', offset).set('limit', limit);
|
||||
if (status) params = params.set('status', status);
|
||||
if (clientName) params = params.set('clientName', clientName);
|
||||
if (fromDate) params = params.set('fromDate', fromDate);
|
||||
if (toDate) params = params.set('toDate', toDate);
|
||||
return this.http.get(`/clients/edit/pending`, { params });
|
||||
}
|
||||
|
||||
approveClientEdit(clientId: string) {
|
||||
return this.http.post(`/clients/${clientId}/edit/approve`, {});
|
||||
}
|
||||
|
||||
rejectClientEdit(clientId: string, rejectReason?: string) {
|
||||
return this.http.post(`/clients/${clientId}/edit/reject`, { rejectReason: rejectReason || '' });
|
||||
}
|
||||
|
||||
getClientDataAndTemplate(clientId: string) {
|
||||
const httpParams = new HttpParams().set('template', 'true').set('staffInSelectedOfficeOnly', 'true');
|
||||
return this.http.get(`/clients/${clientId}`, { params: httpParams });
|
||||
|
||||
@@ -25,9 +25,22 @@
|
||||
{{ 'labels.inputs.GENERAL' | translate }}
|
||||
</ng-template>
|
||||
<div class="tab-content" [class.tab-content--readonly]="isTabReadOnly(0)">
|
||||
@if (hasPendingGeneralEdit) {
|
||||
<div class="pending-edit-banner">
|
||||
<mat-icon>hourglass_top</mat-icon>
|
||||
<span
|
||||
>An edit is awaiting approval from another user. You can't submit another edit until it's resolved.</span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
<div class="tab-mode-bar">
|
||||
@if (isTabReadOnly(0)) {
|
||||
<button mat-flat-button class="tab-mode-btn tab-mode-btn--edit" (click)="enableTabEdit(0)">
|
||||
<button
|
||||
mat-flat-button
|
||||
class="tab-mode-btn tab-mode-btn--edit"
|
||||
[disabled]="hasPendingGeneralEdit"
|
||||
(click)="enableTabEdit(0)"
|
||||
>
|
||||
<mat-icon>edit</mat-icon> Edit
|
||||
</button>
|
||||
} @else {
|
||||
|
||||
@@ -217,6 +217,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Pending Edit Approval Banner ─── */
|
||||
.pending-edit-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
margin: 0 0 16px;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #93c5fd;
|
||||
border-radius: 8px;
|
||||
color: #1e40af;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
mat-icon {
|
||||
color: #2563eb;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Re-KYC Alert Banner ─── */
|
||||
.rekyc-alert {
|
||||
display: flex;
|
||||
|
||||
@@ -38,6 +38,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { ORIGINAL_VALUES_PAYLOAD_KEY, buildOriginalValuesSnapshot } from '../shared/client-edit-diff.util';
|
||||
|
||||
/**
|
||||
* Edit Client Component — uses the same wizard/stepper layout as Create Client.
|
||||
@@ -116,6 +117,9 @@ export class EditClientComponent {
|
||||
clientDataAndTemplate: any;
|
||||
/** Client ID */
|
||||
clientId: string;
|
||||
/** True while a General-tab edit is staged awaiting checker approval — blocks submitting
|
||||
* another one (the backend enforces this too; this just avoids a round-trip error toast). */
|
||||
hasPendingGeneralEdit = false;
|
||||
|
||||
constructor() {
|
||||
this.route.data.subscribe((data: { clientDataAndTemplate: any }) => {
|
||||
@@ -124,6 +128,15 @@ export class EditClientComponent {
|
||||
if (this.clientDataAndTemplate.legalForm) {
|
||||
this.legalFormType = this.clientDataAndTemplate.legalForm.id;
|
||||
}
|
||||
this.loadPendingGeneralEdit();
|
||||
});
|
||||
}
|
||||
|
||||
private loadPendingGeneralEdit(): void {
|
||||
if (!this.clientId) return;
|
||||
this.clientsService.getPendingClientEdit(this.clientId).subscribe({
|
||||
next: (edit: any) => (this.hasPendingGeneralEdit = !!edit && edit.status === 'PENDING'),
|
||||
error: () => (this.hasPendingGeneralEdit = false)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -434,15 +447,27 @@ export class EditClientComponent {
|
||||
clientData.emailNewOtpId = this.newEmailOtpId;
|
||||
}
|
||||
|
||||
// Point-in-time "before" snapshot for the checker's diff view — captured now, from the
|
||||
// client record loaded when this edit page opened, rather than relying on comparing against
|
||||
// the LIVE client later (which would show nothing changed once a checker approves the edit,
|
||||
// since by then current == proposed).
|
||||
clientData[ORIGINAL_VALUES_PAYLOAD_KEY] = buildOriginalValuesSnapshot(originalData);
|
||||
|
||||
this.savingClient = true;
|
||||
this.clientsService.updateClient(this.clientId, clientData).subscribe({
|
||||
// Maker-checker: stage the edit instead of applying it immediately. The customer-facing
|
||||
// side effects that a direct update would have had immediately — SMS/Email contact-change
|
||||
// notification, re-KYC flagging, PAN re-verification reset — are all deferred until a
|
||||
// different user actually approves the edit (approval replays this exact payload through
|
||||
// the same updateClient() the direct PUT used to call, so the backend-driven notification
|
||||
// still fires correctly, just at approval time instead of now).
|
||||
this.clientsService.submitClientEdit(this.clientId, clientData).subscribe({
|
||||
next: () => {
|
||||
this.savingClient = false;
|
||||
this.tabEditMode[tabIndex] = false;
|
||||
this.hasPendingGeneralEdit = true;
|
||||
|
||||
// Senior Citizen / PWD supporting documents — same pattern as create-client's
|
||||
// post-creation upload, triggered here since these are plain file inputs on the
|
||||
// General step, not part of the JSON update payload.
|
||||
// Senior Citizen / PWD supporting documents — separate upload endpoints, not part of
|
||||
// the staged JSON edit, so these still apply immediately as before.
|
||||
if (this.clientGeneralStep?.seniorCitizenProofFile) {
|
||||
const seniorCitizenProofForm = new FormData();
|
||||
seniorCitizenProofForm.append('file', this.clientGeneralStep.seniorCitizenProofFile);
|
||||
@@ -460,40 +485,13 @@ export class EditClientComponent {
|
||||
this.clientsService.uploadClientDocument(this.clientId, pwdCertificateForm).subscribe({ error: () => {} });
|
||||
}
|
||||
|
||||
// Log mobile/email changes as client notes for risk audit
|
||||
this.logFieldChangeNotes(changedFields);
|
||||
|
||||
// If name or DOB changed, flag re-KYC requirement and reset PAN verification
|
||||
if (changedFields.nameChanged || changedFields.dobChanged) {
|
||||
this.dobChanged = true;
|
||||
this.tabEditMode[1] = true; // auto-enable KYC tab for re-verification
|
||||
this.flagReKycRequired(changedFields);
|
||||
}
|
||||
|
||||
// Update original data snapshot so subsequent edits compare against latest
|
||||
if (changedFields.mobileChanged) {
|
||||
originalData.mobileNo = changedFields.newMobile;
|
||||
}
|
||||
if (changedFields.emailChanged) {
|
||||
originalData.emailAddress = changedFields.newEmail;
|
||||
}
|
||||
if (changedFields.dobChanged) {
|
||||
originalData.dateOfBirth = changedFields.newDob;
|
||||
}
|
||||
if (changedFields.nameChanged) {
|
||||
const form = this.clientGeneralForm;
|
||||
originalData.firstname = form?.get('firstname')?.value ?? originalData.firstname;
|
||||
originalData.middlename = form?.get('middlename')?.value ?? originalData.middlename;
|
||||
originalData.lastname = form?.get('lastname')?.value ?? originalData.lastname;
|
||||
originalData.fullname = form?.get('fullname')?.value ?? originalData.fullname;
|
||||
}
|
||||
|
||||
// Notification is backend-driven (SMS + Email) — just reassure staff it happened.
|
||||
if (changedFields.mobileChanged || changedFields.emailChanged) {
|
||||
this.snackBar.open('Customer notified via SMS/Email of the contact change.', 'Close', {
|
||||
duration: 4000
|
||||
});
|
||||
}
|
||||
this.snackBar.open(
|
||||
'Edit submitted for approval — a different user must review it before it takes effect.',
|
||||
'Close',
|
||||
{
|
||||
duration: 5000
|
||||
}
|
||||
);
|
||||
this.resetContactVerificationState();
|
||||
|
||||
this.selectedTabIndex = this.selectedTabIndex + 1;
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shared between the maker side (edit-client, which captures a point-in-time snapshot of the
|
||||
* client's current values right before staging an edit) and the checker side (pending-client-edits,
|
||||
* which diffs the staged payload against that snapshot). Keeping both in one place means the two
|
||||
* sides can never drift out of sync on which fields are captured/compared/labelled.
|
||||
*
|
||||
* Extracts a payload key's CURRENT value from a client record (as returned by GET /clients/{id}) —
|
||||
* CodeValue fields come back as nested {id, name} objects, so most extractors pull the nested .id
|
||||
* to compare against the payload's raw id.
|
||||
*/
|
||||
export const PENDING_EDIT_FIELD_EXTRACTORS: Record<string, (c: any) => any> = {
|
||||
officeId: (c) => c.officeId,
|
||||
legalFormId: (c) => c.legalForm?.id,
|
||||
nationalityId: (c) => c.nationality,
|
||||
isStaff: (c) => c.isStaff,
|
||||
active: (c) => c.active,
|
||||
accountNo: (c) => c.accountNo,
|
||||
salutationId: (c) => c.salutation?.id,
|
||||
firstname: (c) => c.firstname,
|
||||
middlename: (c) => c.middlename,
|
||||
lastname: (c) => c.lastname,
|
||||
fullname: (c) => c.fullname,
|
||||
genderId: (c) => c.gender?.id,
|
||||
maritalStatusId: (c) => c.maritalStatus?.id,
|
||||
educationLevelId: (c) => c.educationLevel?.id,
|
||||
degreeQualificationId: (c) => c.degreeQualification?.id,
|
||||
religionId: (c) => c.religion?.id,
|
||||
socialCategoryId: (c) => c.socialCategory?.id,
|
||||
mobileNo: (c) => c.mobileNo,
|
||||
alternateMobileNo: (c) => c.alternateMobileNo,
|
||||
emailAddress: (c) => c.emailAddress,
|
||||
dateOfBirth: (c) => c.dateOfBirth,
|
||||
occupationCategoryId: (c) => c.occupationCategory?.id,
|
||||
occupationId: (c) => c.occupation?.id,
|
||||
monthlyIncome: (c) => c.monthlyIncome,
|
||||
annualIncome: (c) => c.annualIncome,
|
||||
annualTurnover: (c) => c.annualTurnover,
|
||||
monthlyTurnover: (c) => c.monthlyTurnover,
|
||||
isPwd: (c) => c.isPwd,
|
||||
pwdCategoryId: (c) => c.pwdCategory?.id,
|
||||
isSeniorCitizen: (c) => c.isSeniorCitizen,
|
||||
agricultureIncome: (c) => c.agricultureIncome,
|
||||
nonAgricultureIncome: (c) => c.nonAgricultureIncome,
|
||||
savingsProductId: (c) => c.savingsProductId,
|
||||
staffId: (c) => c.staffId,
|
||||
guardianClientId: (c) => c.guardianClientId,
|
||||
guardianRelation: (c) => c.guardianRelation
|
||||
};
|
||||
|
||||
/** Payload keys that are computed, internal, or otherwise never meaningfully "changed" from a
|
||||
* checker's point of view — always sent by the form (age breakdown, OTP tracking ids, the
|
||||
* business-date default on submittedOnDate) but with no comparable "current" value. */
|
||||
export const NON_COMPARABLE_KEYS = new Set([
|
||||
'dateFormat',
|
||||
'locale',
|
||||
'ageYears',
|
||||
'ageMonths',
|
||||
'ageDays',
|
||||
'isMinor',
|
||||
'addSavings',
|
||||
'submittedOnDate',
|
||||
'activationDate',
|
||||
// Flattened onto top-level guardianClientId/guardianRelation right after this in the payload —
|
||||
// that flattened pair already has real comparators above, so the nested group is redundant.
|
||||
'guardianDetails',
|
||||
// Mobile/email DIP verification + name-match + sanctions-screening results — carried along on
|
||||
// every save to re-affirm the client's EXISTING verification state, not something the maker
|
||||
// edited on this pass. dipTimestamp especially would almost never string-match exactly even
|
||||
// when functionally unchanged, so these were never real "proposed changes" to review.
|
||||
'dipVerified',
|
||||
'dipLookupLogId',
|
||||
'dipTimestamp',
|
||||
'alternateDipVerified',
|
||||
'alternateDipLookupLogId',
|
||||
'alternateDipTimestamp',
|
||||
'nameVerified',
|
||||
'screeningLogId',
|
||||
'mobileNewOtpId',
|
||||
'mobileOldOtpId',
|
||||
'emailNewOtpId',
|
||||
'emailOldOtpId'
|
||||
]);
|
||||
|
||||
export const PENDING_EDIT_FIELD_LABELS: Record<string, string> = {
|
||||
officeId: 'Office',
|
||||
legalFormId: 'Legal Form',
|
||||
nationalityId: 'Nationality',
|
||||
isStaff: 'Is Staff',
|
||||
active: 'Active',
|
||||
accountNo: 'Account Number',
|
||||
salutationId: 'Salutation',
|
||||
firstname: 'First Name',
|
||||
middlename: 'Middle Name',
|
||||
lastname: 'Last Name',
|
||||
fullname: 'Full Name',
|
||||
genderId: 'Gender',
|
||||
maritalStatusId: 'Marital Status',
|
||||
educationLevelId: 'Education Level',
|
||||
degreeQualificationId: 'Degree / Qualification',
|
||||
religionId: 'Religion',
|
||||
socialCategoryId: 'Social Category',
|
||||
mobileNo: 'Mobile Number',
|
||||
alternateMobileNo: 'Alternate Mobile Number',
|
||||
emailAddress: 'Email Address',
|
||||
dateOfBirth: 'Date of Birth',
|
||||
occupationCategoryId: 'Occupation Category',
|
||||
occupationId: 'Occupation',
|
||||
monthlyIncome: 'Monthly Income',
|
||||
annualIncome: 'Annual Income',
|
||||
annualTurnover: 'Yearly Turnover',
|
||||
monthlyTurnover: 'Monthly Turnover',
|
||||
isPwd: 'Person with Disability',
|
||||
pwdCategoryId: 'Disability Type',
|
||||
isSeniorCitizen: 'Senior Citizen',
|
||||
agricultureIncome: 'Agriculture Income',
|
||||
nonAgricultureIncome: 'Non-Agriculture Income',
|
||||
submittedOnDate: 'Submitted On',
|
||||
savingsProductId: 'Linked Savings Product',
|
||||
staffId: 'Staff'
|
||||
};
|
||||
|
||||
/** Key under which the maker's point-in-time "before" snapshot rides along in the submit payload —
|
||||
* stripped out by the backend before replaying the rest through updateClient(), and stored
|
||||
* separately as original_json for the checker's diff. Never a real Client field name, so it can't
|
||||
* collide with anything ClientDataValidator actually expects. */
|
||||
export const ORIGINAL_VALUES_PAYLOAD_KEY = '_originalValues';
|
||||
|
||||
/** Builds the maker-side "before" snapshot from the client record loaded when the edit page
|
||||
* opened — flat key names matching the payload's own, so the checker side can diff two plain
|
||||
* objects directly without needing to know Client's nested response shape at all. */
|
||||
export function buildOriginalValuesSnapshot(clientData: any): Record<string, any> {
|
||||
const snapshot: Record<string, any> = {};
|
||||
if (!clientData) return snapshot;
|
||||
for (const key of Object.keys(PENDING_EDIT_FIELD_EXTRACTORS)) {
|
||||
const value = PENDING_EDIT_FIELD_EXTRACTORS[key](clientData);
|
||||
if (value !== undefined) {
|
||||
snapshot[key] = value;
|
||||
}
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
Reference in New Issue
Block a user