Files
cbsnexora/src/app/tasks/checker-inbox-and-tasks-tabs/client-approval/client-approval.component.ts
T
keshav 7b895a0e2b
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
feat: add KYC document renewal alerts, Aadhaar/PAN exceptions, and AML recurring alerts UI; remove old journal AML marking
2026-08-11 11:11:18 +05:30

320 lines
10 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/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { SelectionModel } from '@angular/cdk/collections';
import * as _ from 'lodash';
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';
/** Dialog Imports */
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
import { DatepickerBase } from 'app/shared/form-dialog/formfield/model/datepicker-base';
/** Custom Services */
import { TasksService } from '../../tasks.service';
import { SettingsService } from 'app/settings/settings.service';
import { AlertService } from 'app/core/alert/alert.service';
import { Dates } from 'app/core/utils/dates';
import { KeyValuePipe } from '@angular/common';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatCheckbox } from '@angular/material/checkbox';
import { AccountsFilterPipe } from '../../../pipes/accounts-filter.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Checker-facing list of Clients pending approval.
* Pagination, name search, and submitted-date filtering all happen server-side via the
* standard /clients list API (offset/limit, displayName, fromDate/toDate) — mirrors
* PendingClientEditsComponent's load()/onPageChange()/applyPreset() pattern.
*/
@Component({
selector: 'mifosx-client-approval',
templateUrl: './client-approval.component.html',
styleUrls: ['./client-approval.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCheckbox,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
KeyValuePipe,
AccountsFilterPipe,
MatPaginatorModule
]
})
export class ClientApprovalComponent implements OnInit {
private dialog = inject(MatDialog);
private dateUtils = inject(Dates);
private settingsService = inject(SettingsService);
private tasksService = inject(TasksService);
private alertService = inject(AlertService);
/** Current page of pending clients (from the server), grouped by office for display */
groupedClients: any = {};
/** True once the first page has loaded and it contains at least one client */
showData = false;
loading = false;
loaded = false;
/** Row Selection Data — selection lives across the whole session, not just the current page */
selection = new SelectionModel<any>(true, []);
/** Batch Requests */
batchRequests: any[];
/** Displayed Columns */
displayedColumns: string[] = [
'select',
'name',
'cifNumber',
'staff'
];
totalRecords = 0;
pageSize = 20;
pageIndex = 0;
pageSizeOptions = [
10,
20,
50
];
searchTerm = '';
activePreset = 'all';
readonly datePresets = [
{ key: 'all', label: 'All requests' },
{ key: 'today', label: 'Today' },
{ key: 'yesterday', label: 'Yesterday' }
];
private fromDate: string | undefined;
private toDate: string | undefined;
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.tasksService
.getGroupedClientsData(
this.searchTerm.trim() || undefined,
this.fromDate,
this.toDate,
this.pageIndex * this.pageSize,
this.pageSize
)
.subscribe({
next: (res: any) => {
const clients = res?.pageItems ?? [];
this.groupedClients = _.groupBy(clients, 'officeName');
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
this.showData = this.totalRecords > 0 || Object.keys(this.groupedClients).length > 0;
this.loading = false;
this.loaded = true;
},
error: () => {
this.groupedClients = {};
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.searchTerm = 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 = this.isoDate(today);
this.toDate = this.isoDate(today);
} else if (key === 'yesterday') {
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
this.fromDate = this.isoDate(yesterday);
this.toDate = this.isoDate(yesterday);
} else {
this.fromDate = undefined;
this.toDate = undefined;
}
this.pageIndex = 0;
this.load();
}
private isoDate(d: Date): string {
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}`;
}
/** Whether the number of selected elements matches the total number of rows. */
isAllSelected(dataSource2: any) {
if (dataSource2) {
const numSelected = this.selection.selected;
return _.difference(dataSource2, numSelected).length === 0;
}
}
/** Selects all rows if they are not all selected; otherwise clear selection. */
masterToggle(dataSource3: any) {
if (dataSource3) {
this.isAllSelected(dataSource3)
? dataSource3.forEach((row: any) => this.selection.deselect(row))
: dataSource3.forEach((row: any) => this.selection.select(row));
}
}
/** The label for the checkbox on the passed row */
checkboxLabel(row?: any): string {
if (!row) {
return `${this.isAllSelected(row) ? 'select' : 'deselect'} all`;
}
return `${this.selection.isSelected(row) ? 'deselect' : 'select'} row ${row.position + 1}`;
}
approveClients() {
const formfields: FormfieldBase[] = [
new DatepickerBase({
controlName: 'actDate',
label: 'Date',
value: new Date(),
type: 'datetime-local',
required: true
})
];
const data = {
title: 'Enter Clients Activation Date',
layout: { addButtonText: 'Confirm' },
formfields: formfields,
// The date is pre-filled with today, so the form is already valid on open. Without this the
// dialog stays pristine and Confirm is disabled until the user needlessly re-picks the date.
pristine: false
};
const clientApprovalDialogRef = this.dialog.open(FormDialogComponent, { data });
clientApprovalDialogRef.afterClosed().subscribe((response: any) => {
// Cancel / the X button close with no value, so response is undefined there.
if (response?.data) {
this.bulkClientApproval(response.data);
}
});
}
bulkClientApproval(submittedData: any) {
const dateFormat = this.settingsService.dateFormat;
const activationDate = this.dateUtils.formatDate(submittedData.value.actDate, dateFormat);
const locale = this.settingsService.language.code;
const formData = {
dateFormat,
activationDate,
locale
};
const listSelectedAccounts = this.selection.selected;
this.batchRequests = [];
let reqId = 1;
// Keep the client each request belongs to, so a rejection can name who it was for.
const clientsByRequestId = new Map<number, any>();
listSelectedAccounts.forEach((element: any) => {
const url = 'clients/' + element.id + '?command=activate';
const bodyData = JSON.stringify(formData);
const batchData = { requestId: reqId++, relativeUrl: url, method: 'POST', body: bodyData };
clientsByRequestId.set(batchData.requestId, element);
this.batchRequests.push(batchData);
});
this.tasksService.submitBatchData(this.batchRequests).subscribe((response: any) => {
let activatedAccounts = 0;
const failures: string[] = [];
(response ?? []).forEach((responseEle: any) => {
// The batch API returns statusCode as a number, so the old `=== '200'` string comparison
// never matched: successes were never counted (no refresh) and rejections were dropped
// entirely, which made a failed approval look like nothing had happened at all.
if (Number(responseEle.statusCode) === 200) {
activatedAccounts++;
} else {
const client = clientsByRequestId.get(Number(responseEle.requestId));
const name = client?.displayName ?? `Client ${client?.id ?? ''}`.trim();
failures.push(`${name}: ${this.batchErrorMessage(responseEle)}`);
}
});
if (failures.length) {
this.alertService.alert({
type: 'Client Approval Failed',
severity: 'error',
message:
(activatedAccounts ? `${activatedAccounts} approved. ` : '') +
`${failures.length} could not be approved — ${failures.join('; ')}`
});
}
// Refresh whenever anything changed, rather than only when every single one succeeded —
// a partial batch previously left the approved clients sitting in the list.
if (activatedAccounts) {
this.selection.clear();
this.reload();
}
});
}
/** Pulls the human-readable reason out of a failed batch entry's JSON body. */
private batchErrorMessage(responseEle: any): string {
try {
const body = typeof responseEle.body === 'string' ? JSON.parse(responseEle.body) : responseEle.body;
return (
body?.errors?.[0]?.defaultUserMessage ||
body?.errors?.[0]?.developerMessage ||
body?.defaultUserMessage ||
body?.developerMessage ||
`request failed with status ${responseEle.statusCode}`
);
} catch {
return `request failed with status ${responseEle.statusCode}`;
}
}
/**
* Refetches the current page after a bulk approval.
*/
reload() {
this.load();
}
}