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:
2026-07-29 10:27:45 +05:30
co-authored by Claude Sonnet 5
parent 79fedb66e5
commit 468eaa96ab
14 changed files with 1412 additions and 40 deletions
+37
View File
@@ -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}`);
@@ -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 &mdash; 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>
@@ -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;
}
@@ -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);
}
@@ -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">&mdash;</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&hellip;</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>
@@ -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;
}
}
@@ -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"
+6
View File
@@ -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,