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:
@@ -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