/** * 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 = { 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 = { maritalStatusOptions: 'MaritalStatus', educationLevelOptions: 'EducationLevel', degreeQualificationOptions: 'DegreeQualification', religionOptions: 'Religion', socialCategoryOptions: 'SocialCategory' }; const NATIONALITY_LABELS: Record = { 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, ClientEditEmailDialogComponent ] }) 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 = {}; 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 = { 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; try { parsed = JSON.parse(edit.payloadJson); } catch { return []; } let original: Record | 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(); } }