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;
|
||||
}
|
||||
@@ -54,7 +54,11 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
|
||||
const isClientImageRequest = request.url.includes('/clients/') && request.url.includes('/images');
|
||||
const isClientKycRequest = request.url.match(/\/clients\/\d+\/kyc$/);
|
||||
const isDirectorRequest = request.url.includes('/election/directors');
|
||||
const isSilentRequest = isClientImageRequest || isClientKycRequest || isDirectorRequest;
|
||||
// "No pending edit for this client" is the normal/expected state — checked on every edit-page
|
||||
// load, not a real error the user needs to see.
|
||||
const isClientPendingEditRequest = request.url.match(/\/clients\/\d+\/edit\/pending$/);
|
||||
const isSilentRequest =
|
||||
isClientImageRequest || isClientKycRequest || isDirectorRequest || isClientPendingEditRequest;
|
||||
|
||||
if (!environment.production && !isSilentRequest) {
|
||||
log.error(`Request Error: ${errorMessage}`);
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<h1 mat-dialog-title>
|
||||
Proposed Changes — Client
|
||||
<a [routerLink]="['/clients', data.clientId, 'general']" mat-dialog-close class="cecd-client-link">{{
|
||||
data.clientDisplayName
|
||||
}}</a>
|
||||
</h1>
|
||||
|
||||
<div mat-dialog-content class="cecd-content">
|
||||
<table class="cecd-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Current Value</th>
|
||||
<th>Proposed Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of data.rows; track row.key) {
|
||||
<tr>
|
||||
<td class="cecd-label">{{ row.label }}</td>
|
||||
<td class="cecd-current">{{ row.current }}</td>
|
||||
<td class="cecd-proposed">{{ row.value }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="3" class="cecd-empty">No changed fields detected.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if (data.status && data.status !== 'PENDING' && data.checkerName) {
|
||||
<p class="cecd-resolution-note">
|
||||
<strong>{{ data.status === 'APPROVED' ? 'Approved' : 'Rejected' }} by</strong> {{ data.checkerName }}
|
||||
@if (data.checkedDate) {
|
||||
on {{ data.checkedDate }}
|
||||
}
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (data.rejectReason) {
|
||||
<p class="cecd-reject-reason"><strong>Reject Reason:</strong> {{ data.rejectReason }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-raised-button mat-dialog-close color="primary">Close</button>
|
||||
</mat-dialog-actions>
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
$border: #e2e8f0;
|
||||
$text-muted: #64748b;
|
||||
|
||||
.cecd-client-link {
|
||||
color: #1e40af;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.cecd-content {
|
||||
min-width: 420px;
|
||||
max-width: 70vw;
|
||||
// Explicit scroll behavior rather than relying only on Material's default dialog-content
|
||||
// sizing — with many changed fields this keeps the dialog itself a fixed height (Close button
|
||||
// always visible) and scrolls just the table.
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.cecd-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid $border;
|
||||
}
|
||||
|
||||
th {
|
||||
color: $text-muted;
|
||||
font-weight: 600;
|
||||
// Stays visible while scrolling through a long list of changed fields.
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #fff;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.cecd-label {
|
||||
font-weight: 600;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
.cecd-current {
|
||||
color: #b91c1c;
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: #fca5a5;
|
||||
}
|
||||
|
||||
.cecd-proposed {
|
||||
color: #15803d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cecd-empty {
|
||||
color: $text-muted;
|
||||
font-size: 12.5px;
|
||||
text-align: center;
|
||||
padding: 16px 10px;
|
||||
}
|
||||
|
||||
.cecd-resolution-note {
|
||||
margin-top: 14px;
|
||||
font-size: 12.5px;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.cecd-reject-reason {
|
||||
margin-top: 14px;
|
||||
font-size: 12.5px;
|
||||
color: #b91c1c;
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import {
|
||||
MatDialogRef,
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { CdkScrollable } from '@angular/cdk/scrolling';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
export interface ClientEditCompareRow {
|
||||
key: string;
|
||||
label: string;
|
||||
current: any;
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface ClientEditCompareDialogData {
|
||||
clientId: string;
|
||||
clientDisplayName: string;
|
||||
rows: ClientEditCompareRow[];
|
||||
status?: string;
|
||||
checkerName?: string;
|
||||
checkedDate?: string | null;
|
||||
rejectReason?: string;
|
||||
}
|
||||
|
||||
/** Popup version of the Pending Client Edits "Compare" panel — shows the Field/Current/Proposed
|
||||
* diff in an in-app modal instead of an inline expansion below the table. */
|
||||
@Component({
|
||||
selector: 'mifosx-client-edit-compare-dialog',
|
||||
templateUrl: './client-edit-compare-dialog.component.html',
|
||||
styleUrls: ['./client-edit-compare-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatDialogTitle,
|
||||
CdkScrollable,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
]
|
||||
})
|
||||
export class ClientEditCompareDialogComponent {
|
||||
dialogRef = inject<MatDialogRef<ClientEditCompareDialogComponent>>(MatDialogRef);
|
||||
data = inject<ClientEditCompareDialogData>(MAT_DIALOG_DATA);
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="pce-tab-content">
|
||||
<div class="pce-action-bar">
|
||||
<div class="pce-search-box">
|
||||
<mat-icon>search</mat-icon>
|
||||
<input
|
||||
#filterInput
|
||||
type="text"
|
||||
placeholder="Search by client name"
|
||||
(keyup.enter)="applyFilter(filterInput.value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select class="pce-filter-select" [(ngModel)]="filterStatus" (change)="onFilterChange()">
|
||||
@for (opt of statusOptions; track opt.value) {
|
||||
<option [value]="opt.value">{{ opt.label }}</option>
|
||||
}
|
||||
</select>
|
||||
|
||||
<span class="pce-fb-sep"></span>
|
||||
|
||||
<div class="pce-presets">
|
||||
@for (p of datePresets; track p.key) {
|
||||
<button
|
||||
type="button"
|
||||
class="pce-preset-chip"
|
||||
[class.pce-preset-chip--active]="activePreset === p.key"
|
||||
(click)="applyPreset(p.key)"
|
||||
>
|
||||
{{ p.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<span class="pce-fb-sep"></span>
|
||||
|
||||
<div class="pce-date-range">
|
||||
<div class="pce-date-box">
|
||||
<span class="pce-date-box__lbl">From</span>
|
||||
<input
|
||||
class="pce-date-box__input"
|
||||
[matDatepicker]="fromPicker"
|
||||
[(ngModel)]="fromDate"
|
||||
[max]="maxDate"
|
||||
(dateChange)="onFromDateChange()"
|
||||
readonly
|
||||
/>
|
||||
<button type="button" class="pce-date-box__btn" (click)="fromPicker.open()">
|
||||
<mat-icon>event</mat-icon>
|
||||
</button>
|
||||
<mat-datepicker #fromPicker></mat-datepicker>
|
||||
</div>
|
||||
<span class="pce-date-range__sep">—</span>
|
||||
<div class="pce-date-box">
|
||||
<span class="pce-date-box__lbl">To</span>
|
||||
<input
|
||||
class="pce-date-box__input"
|
||||
[matDatepicker]="toPicker"
|
||||
[(ngModel)]="toDate"
|
||||
[min]="fromDate"
|
||||
[max]="maxDate"
|
||||
(dateChange)="onToDateChange()"
|
||||
readonly
|
||||
/>
|
||||
<button type="button" class="pce-date-box__btn" (click)="toPicker.open()">
|
||||
<mat-icon>event</mat-icon>
|
||||
</button>
|
||||
<mat-datepicker #toPicker></mat-datepicker>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="pce-btn pce-btn-refresh" (click)="load()" [disabled]="loading">
|
||||
<fa-icon icon="sync-alt"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="empty-state">
|
||||
<p class="empty-title">Loading…</p>
|
||||
</div>
|
||||
} @else if (loaded && edits.length === 0) {
|
||||
<div class="empty-state">
|
||||
<fa-icon icon="hourglass-half" class="empty-icon"></fa-icon>
|
||||
<p class="empty-title">No {{ filterStatus | lowercase }} client edits</p>
|
||||
<p class="empty-text">
|
||||
No Client details edits found for this filter. Try
|
||||
<a (click)="applyPreset('all')" class="pce-client-link">All</a> dates, or a different status above.
|
||||
</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="table-container">
|
||||
<table mat-table [dataSource]="edits" class="modern-table">
|
||||
<ng-container matColumnDef="clientDisplayName">
|
||||
<th mat-header-cell *matHeaderCellDef>Client</th>
|
||||
<td mat-cell *matCellDef="let edit">
|
||||
<a [routerLink]="['/clients', edit.clientId, 'general']" class="pce-client-link">{{
|
||||
edit.clientDisplayName
|
||||
}}</a>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="makerName">
|
||||
<th mat-header-cell *matHeaderCellDef>Submitted By</th>
|
||||
<td mat-cell *matCellDef="let edit">{{ edit.makerName }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="submittedDate">
|
||||
<th mat-header-cell *matHeaderCellDef>Submitted On</th>
|
||||
<td mat-cell *matCellDef="let edit">{{ formatDate(edit.submittedDate) }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let edit">
|
||||
<span class="status-badge" [ngClass]="statusClass(edit.status)">{{ edit.status }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let edit">
|
||||
<button mat-stroked-button class="pce-btn pce-btn-view" (click)="viewCompare(edit)">
|
||||
<fa-icon icon="eye" class="m-r-10"></fa-icon>Compare
|
||||
</button>
|
||||
@if (edit.status === 'PENDING') {
|
||||
<button mat-stroked-button class="pce-btn pce-btn-approve" (click)="approve(edit)">
|
||||
<fa-icon icon="check" class="m-r-10"></fa-icon>Approve
|
||||
</button>
|
||||
<button mat-stroked-button class="pce-btn pce-btn-reject" (click)="reject(edit)">
|
||||
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row"></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<mat-paginator
|
||||
[length]="totalRecords"
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
[pageIndex]="pageIndex"
|
||||
showFirstLastButtons
|
||||
(page)="onPageChange($event)"
|
||||
></mat-paginator>
|
||||
}
|
||||
</div>
|
||||
+348
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
$border: #e2e8f0;
|
||||
$text-muted: #64748b;
|
||||
|
||||
.pce-tab-content {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.pce-client-link {
|
||||
color: #1e40af;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.pce-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid $border;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pce-search-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:focus-within {
|
||||
border-color: #1353a0;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
color: #94a3b8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
color: #0d1b2a;
|
||||
font-family: inherit;
|
||||
background: transparent;
|
||||
|
||||
&::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pce-filter-select {
|
||||
height: 34px;
|
||||
min-width: 120px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #0d1b2a;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #1353a0;
|
||||
}
|
||||
}
|
||||
|
||||
.pce-fb-sep {
|
||||
width: 1px;
|
||||
height: 22px;
|
||||
background: $border;
|
||||
flex-shrink: 0;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.pce-presets {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pce-preset-chip {
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid $border;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
border-color: #1353a0;
|
||||
color: #1353a0;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: #1353a0;
|
||||
border-color: #1353a0;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: #0f3f7a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pce-date-range {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
&__sep {
|
||||
color: #94a3b8;
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
padding: 0 2px;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.pce-date-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 34px;
|
||||
padding: 0 6px 0 10px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
border-color: #1353a0;
|
||||
}
|
||||
|
||||
&__lbl {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
&__input {
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
width: 76px;
|
||||
color: #0d1b2a;
|
||||
font-family: inherit;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: #94a3b8;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #1353a0;
|
||||
background: #eff6ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pce-btn {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px !important;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
margin-right: 6px;
|
||||
|
||||
fa-icon {
|
||||
margin-right: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.pce-btn-view {
|
||||
border-color: #2563eb;
|
||||
color: #2563eb;
|
||||
|
||||
&:hover {
|
||||
background: #eff6ff;
|
||||
}
|
||||
}
|
||||
|
||||
.pce-btn-refresh {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
color: #475569;
|
||||
margin-left: auto;
|
||||
|
||||
fa-icon {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: #1353a0;
|
||||
color: #1353a0;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.pce-btn-approve {
|
||||
border-color: #16a34a;
|
||||
color: #16a34a;
|
||||
|
||||
&:hover {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
}
|
||||
|
||||
.pce-btn-reject {
|
||||
border-color: #dc2626;
|
||||
color: #dc2626;
|
||||
|
||||
&:hover {
|
||||
background: #fef2f2;
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
|
||||
&--pending {
|
||||
background: #fef9c3;
|
||||
color: #854d0e;
|
||||
}
|
||||
|
||||
&--approved {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&--rejected {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 24px;
|
||||
background: #fff;
|
||||
border: 1px dashed $border;
|
||||
border-radius: 14px;
|
||||
text-align: center;
|
||||
margin: 16px 0;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 42px;
|
||||
color: #cbd5e1;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: $text-muted;
|
||||
max-width: 380px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow
|
||||
} from '@angular/material/table';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
|
||||
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
|
||||
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
|
||||
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { NON_COMPARABLE_KEYS, PENDING_EDIT_FIELD_LABELS } from 'app/clients/shared/client-edit-diff.util';
|
||||
import { ClientEditCompareDialogComponent } from './client-edit-compare-dialog/client-edit-compare-dialog.component';
|
||||
|
||||
/** Maps a payload key that holds a CodeValue/template id to the options array it should be
|
||||
* resolved against — populated from /clients/template plus the same per-code-value lookups the
|
||||
* General step itself uses, so a checker sees "Male" / "Graduate" instead of a bare id. */
|
||||
const ID_FIELD_OPTIONS_KEY: Record<string, string> = {
|
||||
officeId: 'officeOptions',
|
||||
staffId: 'staffOptions',
|
||||
legalFormId: 'clientLegalFormOptions',
|
||||
salutationId: 'salutationOptions',
|
||||
genderId: 'genderOptions',
|
||||
maritalStatusId: 'maritalStatusOptions',
|
||||
educationLevelId: 'educationLevelOptions',
|
||||
degreeQualificationId: 'degreeQualificationOptions',
|
||||
religionId: 'religionOptions',
|
||||
socialCategoryId: 'socialCategoryOptions',
|
||||
occupationCategoryId: 'occupationCategoryOptions',
|
||||
occupationId: 'occupationOptions',
|
||||
pwdCategoryId: 'pwdCategoryOptions',
|
||||
savingsProductId: 'savingProductOptions'
|
||||
};
|
||||
|
||||
/** Code-value lists the General step fetches individually rather than via /clients/template. */
|
||||
const OPTIONS_KEY_TO_CODE_NAME: Record<string, string> = {
|
||||
maritalStatusOptions: 'MaritalStatus',
|
||||
educationLevelOptions: 'EducationLevel',
|
||||
degreeQualificationOptions: 'DegreeQualification',
|
||||
religionOptions: 'Religion',
|
||||
socialCategoryOptions: 'SocialCategory'
|
||||
};
|
||||
|
||||
const NATIONALITY_LABELS: Record<string, string> = {
|
||||
INDIAN: 'Indian',
|
||||
NRI: 'NRI',
|
||||
FOREIGN_NATIONAL: 'Foreign National',
|
||||
PIO: 'PIO',
|
||||
OCI: 'OCI'
|
||||
};
|
||||
|
||||
/**
|
||||
* Checker-facing list of Client details edits awaiting approval.
|
||||
* Mirrors PendingShareAccountEditsComponent's list + compare + approve/reject pattern — the
|
||||
* "compare" panel here generically diffs whatever keys are in the staged payload_json, since
|
||||
* Client has far more editable fields than Share Account's fixed handful.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-pending-client-edits',
|
||||
templateUrl: './pending-client-edits.component.html',
|
||||
styleUrls: ['./pending-client-edits.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow,
|
||||
MatIcon,
|
||||
MatPaginatorModule
|
||||
]
|
||||
})
|
||||
export class PendingClientEditsComponent implements OnInit {
|
||||
private dialog = inject(MatDialog);
|
||||
private clientsService = inject(ClientsService);
|
||||
private settingsService = inject(SettingsService);
|
||||
|
||||
/** Current page of pending edits, as returned by the server — pagination/date/search filtering
|
||||
* all happen server-side now (GET /clients/edit/pending?status&clientName&fromDate&toDate&
|
||||
* offset&limit), mirroring ChequebookRequestsComponent. */
|
||||
edits: any[] = [];
|
||||
totalRecords = 0;
|
||||
pageSize = 20;
|
||||
pageIndex = 0;
|
||||
pageSizeOptions = [
|
||||
10,
|
||||
20,
|
||||
50
|
||||
];
|
||||
loading = false;
|
||||
loaded = false;
|
||||
|
||||
/** Populated from /clients/template + per-code-value lookups, used to resolve raw ids in the
|
||||
* compare panel into human-readable labels. Empty object until it loads — entries() just falls
|
||||
* back to showing the raw id/value until then. */
|
||||
private optionsByKey: Record<string, any[]> = {};
|
||||
|
||||
displayedColumns: string[] = [
|
||||
'clientDisplayName',
|
||||
'makerName',
|
||||
'submittedDate',
|
||||
'status',
|
||||
'actions'
|
||||
];
|
||||
|
||||
searchText = '';
|
||||
fromDate: Date | null = null;
|
||||
toDate: Date | null = null;
|
||||
maxDate!: Date;
|
||||
activePreset = 'today';
|
||||
readonly datePresets = [
|
||||
{ key: 'today', label: 'Today' },
|
||||
{ key: 'yesterday', label: 'Yesterday' },
|
||||
{ key: 'all', label: 'All' }
|
||||
];
|
||||
|
||||
filterStatus = 'PENDING';
|
||||
readonly statusOptions = [
|
||||
{ value: 'PENDING', label: 'Pending' },
|
||||
{ value: 'APPROVED', label: 'Approved' },
|
||||
{ value: 'REJECTED', label: 'Rejected' }
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
const today = new Date(this.settingsService.businessDate);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
this.maxDate = today;
|
||||
this.loadOptions();
|
||||
this.applyPreset('today');
|
||||
}
|
||||
|
||||
private loadOptions(): void {
|
||||
this.clientsService.getClientTemplate().subscribe((template: any) => {
|
||||
this.optionsByKey = { ...this.optionsByKey, ...(template || {}) };
|
||||
});
|
||||
Object.entries(OPTIONS_KEY_TO_CODE_NAME).forEach(
|
||||
([
|
||||
optionsKey,
|
||||
codeName
|
||||
]) => {
|
||||
this.clientsService.getCodeValuesByName(codeName).subscribe((options: any[]) => {
|
||||
this.optionsByKey[optionsKey] = options || [];
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
onFilterChange(): void {
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.clientsService
|
||||
.getAllPendingClientEdits(
|
||||
this.filterStatus,
|
||||
this.searchText.trim() || undefined,
|
||||
this.isoDate(this.fromDate),
|
||||
this.isoDate(this.toDate),
|
||||
this.pageIndex * this.pageSize,
|
||||
this.pageSize
|
||||
)
|
||||
.subscribe({
|
||||
next: (res: any) => {
|
||||
this.edits = res?.pageItems ?? [];
|
||||
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
},
|
||||
error: () => {
|
||||
this.edits = [];
|
||||
this.totalRecords = 0;
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.pageSize = event.pageSize;
|
||||
this.pageIndex = event.pageIndex;
|
||||
this.load();
|
||||
}
|
||||
|
||||
applyFilter(filterValue: string = ''): void {
|
||||
this.searchText = filterValue;
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
applyPreset(key: string): void {
|
||||
this.activePreset = key;
|
||||
const today = new Date(this.settingsService.businessDate);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (key === 'today') {
|
||||
this.fromDate = new Date(today);
|
||||
this.toDate = new Date(today);
|
||||
} else if (key === 'yesterday') {
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
this.fromDate = yesterday;
|
||||
this.toDate = yesterday;
|
||||
} else {
|
||||
this.fromDate = null;
|
||||
this.toDate = null;
|
||||
}
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
onFromDateChange(): void {
|
||||
this.activePreset = '';
|
||||
if (this.fromDate && this.toDate && this.fromDate > this.toDate) {
|
||||
this.toDate = new Date(this.fromDate);
|
||||
}
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
onToDateChange(): void {
|
||||
this.activePreset = '';
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
private isoDate(d: Date | null): string | undefined {
|
||||
if (!d) return undefined;
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
formatDate(date: any): string {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
return isNaN(d.getTime()) ? String(date) : d.toLocaleString('en-IN');
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'status-badge--pending',
|
||||
APPROVED: 'status-badge--approved',
|
||||
REJECTED: 'status-badge--rejected'
|
||||
};
|
||||
return map[status] ?? '';
|
||||
}
|
||||
|
||||
/** "Field / Current / Proposed" rows for whatever in the staged payload actually differs from
|
||||
* the point-in-time snapshot captured by the maker's browser when the edit page was opened
|
||||
* (stored as original_json — see buildOriginalValuesSnapshot() in client-edit-diff.util.ts).
|
||||
* The payload itself carries every General-step field (changed or not), so this filters it down
|
||||
* to only what the maker actually proposed to change — a TRUE historical diff, unlike comparing
|
||||
* against the LIVE client, which would show nothing once an edit is approved (by then current ==
|
||||
* proposed). Falls back to showing every field unfiltered when there's no original snapshot at
|
||||
* all (edits submitted before this column existed) rather than risk silently hiding a change. */
|
||||
entries(edit: any): { key: string; label: string; current: any; value: any }[] {
|
||||
if (!edit?.payloadJson) return [];
|
||||
let parsed: Record<string, any>;
|
||||
try {
|
||||
parsed = JSON.parse(edit.payloadJson);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
let original: Record<string, any> | null = null;
|
||||
if (edit.originalJson) {
|
||||
try {
|
||||
original = JSON.parse(edit.originalJson);
|
||||
} catch {
|
||||
original = null;
|
||||
}
|
||||
}
|
||||
|
||||
const keys = Object.keys(parsed).filter(
|
||||
(key) => !NON_COMPARABLE_KEYS.has(key) && parsed[key] !== null && parsed[key] !== undefined && parsed[key] !== ''
|
||||
);
|
||||
const rows: { key: string; label: string; current: any; value: any }[] = [];
|
||||
for (const key of keys) {
|
||||
const proposedRaw = parsed[key];
|
||||
const hasOriginal = !!original && Object.prototype.hasOwnProperty.call(original, key);
|
||||
const originalRaw = hasOriginal ? original![key] : undefined;
|
||||
|
||||
// Nested objects (e.g. clientNonPersonDetails for non-individual clients) aren't captured
|
||||
// in the flat original snapshot at all — always show them rather than guess at a diff.
|
||||
if (typeof proposedRaw === 'object' && !Array.isArray(proposedRaw)) {
|
||||
rows.push({ key, label: PENDING_EDIT_FIELD_LABELS[key] || key, current: null, value: proposedRaw });
|
||||
continue;
|
||||
}
|
||||
|
||||
const changed = !original || !hasOriginal || this.normalize(originalRaw) !== this.normalize(proposedRaw);
|
||||
if (!changed) continue;
|
||||
rows.push({
|
||||
key,
|
||||
label: PENDING_EDIT_FIELD_LABELS[key] || key,
|
||||
current: hasOriginal ? this.resolveValue(key, originalRaw) : null,
|
||||
value: this.resolveValue(key, proposedRaw)
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private resolveValue(key: string, value: any): any {
|
||||
if (key === 'nationalityId') {
|
||||
return NATIONALITY_LABELS[value] || value;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'Yes' : 'No';
|
||||
}
|
||||
const optionsKey = ID_FIELD_OPTIONS_KEY[key];
|
||||
const options = optionsKey ? this.optionsByKey[optionsKey] : null;
|
||||
if (options?.length) {
|
||||
const match = options.find((o: any) => String(o.id) === String(value));
|
||||
if (match) return match.name || match.value || value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Normalizes a value (Fineract date array, formatted date string, boolean, id, plain value)
|
||||
* into a comparable string so a genuinely-unchanged field isn't shown as a false "change" just
|
||||
* because one side is an array-date and the other a formatted string. */
|
||||
private normalize(value: any): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (Array.isArray(value) && value.length >= 3 && value.every((v) => typeof v === 'number')) {
|
||||
return `${value[0]}-${value[1]}-${value[2]}`;
|
||||
}
|
||||
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
||||
if (typeof value === 'string') {
|
||||
const asDate = new Date(value);
|
||||
if (!isNaN(asDate.getTime()) && /[a-zA-Z]/.test(value)) {
|
||||
return `${asDate.getFullYear()}-${asDate.getMonth() + 1}-${asDate.getDate()}`;
|
||||
}
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
viewCompare(edit: any): void {
|
||||
this.openCompareDialog(edit);
|
||||
}
|
||||
|
||||
private openCompareDialog(edit: any): void {
|
||||
this.dialog.open(ClientEditCompareDialogComponent, {
|
||||
width: '640px',
|
||||
data: {
|
||||
clientId: edit.clientId,
|
||||
clientDisplayName: edit.clientDisplayName,
|
||||
rows: this.entries(edit),
|
||||
status: edit.status,
|
||||
checkerName: edit.checkerName,
|
||||
checkedDate: edit.checkedDate ? this.formatDate(edit.checkedDate) : null,
|
||||
rejectReason: edit.rejectReason
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
approve(edit: any): void {
|
||||
const dialogRef = this.dialog.open(ConfirmationDialogComponent, {
|
||||
data: {
|
||||
heading: 'Approve Client Edit',
|
||||
dialogContext: `Apply the pending details edit for client "${edit.clientDisplayName}"? This cannot be undone.`,
|
||||
type: 'basic'
|
||||
}
|
||||
});
|
||||
dialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (response?.confirm) {
|
||||
this.clientsService.approveClientEdit(edit.clientId).subscribe(() => this.reload());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
reject(edit: any): void {
|
||||
const formfields: FormfieldBase[] = [
|
||||
new InputBase({
|
||||
controlName: 'rejectReason',
|
||||
label: 'Reason for rejection',
|
||||
value: '',
|
||||
type: 'text',
|
||||
required: false
|
||||
})
|
||||
];
|
||||
const dialogRef = this.dialog.open(FormDialogComponent, {
|
||||
data: {
|
||||
title: `Reject edit for client "${edit.clientDisplayName}"`,
|
||||
layout: { addButtonText: 'Reject' },
|
||||
formfields
|
||||
}
|
||||
});
|
||||
dialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (response?.data) {
|
||||
const rejectReason = response.data.value.rejectReason || '';
|
||||
this.clientsService.rejectClientEdit(edit.clientId, rejectReason).subscribe(() => this.reload());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,18 @@
|
||||
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
||||
<span>Pending Share Account Edits</span>
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
class="ac-tab"
|
||||
[routerLink]="['./pending-client-edits']"
|
||||
routerLinkActive
|
||||
#pendingClientEdits="routerLinkActive"
|
||||
[active]="pendingClientEdits.isActive"
|
||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||
>
|
||||
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
||||
<span>Pending Client Edits</span>
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
class="ac-tab"
|
||||
|
||||
@@ -30,6 +30,7 @@ import { PendingSavingsProductsComponent } from './checker-inbox-and-tasks-tabs/
|
||||
import { ChequebookRequestsComponent } from './checker-inbox-and-tasks-tabs/chequebook-requests/chequebook-requests.component';
|
||||
import { PendingSavingsAccountEditsComponent } from './checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component';
|
||||
import { PendingShareAccountEditsComponent } from './checker-inbox-and-tasks-tabs/pending-share-account-edits/pending-share-account-edits.component';
|
||||
import { PendingClientEditsComponent } from './checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component';
|
||||
import { ViewCheckerInboxComponent } from './view-checker-inbox/view-checker-inbox.component';
|
||||
|
||||
/** Custom Resolvers */
|
||||
@@ -152,6 +153,11 @@ const routes: Routes = [
|
||||
pendingShareAccountEditsData: GetPendingShareAccountEdits
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'pending-client-edits',
|
||||
component: PendingClientEditsComponent,
|
||||
data: { title: 'Pending Client Edits' }
|
||||
},
|
||||
{
|
||||
path: 'withdrawal-requests',
|
||||
component: WithdrawalRequestsComponent,
|
||||
|
||||
Reference in New Issue
Block a user