Files
cbsnexora/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts
T

477 lines
16 KiB
TypeScript

/**
* 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';
import { ClientEditEmailDialogComponent } from './client-edit-email-dialog/client-edit-email-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());
}
});
}
/** Opens the email preview dialog for an edit row. Fetches the email content first (subject,
* body, sent/opened timestamps) via the email-preview endpoint, then opens the dialog. */
viewEmail(edit: any): void {
this.clientsService.getClientEditEmailPreview(edit.clientId, edit.id).subscribe({
next: (preview) => {
try {
this.dialog.open(ClientEditEmailDialogComponent, {
width: '800px',
maxWidth: '96vw',
data: {
emailSubject: preview?.emailSubject || null,
emailBody: preview?.emailBody || null,
emailSent: preview?.emailSent || false,
emailSentAt: preview?.emailSentAt || null,
emailOpenedAt: preview?.emailOpenedAt || null,
emailFrom: preview?.emailFrom || null,
emailTo: preview?.emailTo || null
}
});
} catch (e: any) {
console.error('Dialog open failed (next): ', e);
}
},
error: () => {
try {
// Fallback: open dialog with no-content state
this.dialog.open(ClientEditEmailDialogComponent, {
width: '800px',
maxWidth: '96vw',
data: {
emailSubject: null,
emailBody: null,
emailSent: false,
emailSentAt: null,
emailOpenedAt: null,
emailFrom: null,
emailTo: null
}
});
} catch (e: any) {
console.error('Dialog open failed (error): ', e);
}
}
});
}
reload(): void {
this.load();
}
}