477 lines
16 KiB
TypeScript
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();
|
|
}
|
|
}
|