/** * 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(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(); 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(); } }