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