feat: add Pending Account and Reject Account tabs to Approval Center

- Added Pending Account tab showing savings/FD/RD/current accounts with status=pending (100)
- Added Reject Account tab showing accounts with status=rejected (500)
- Each tab displays: account no, client name, product, type badge, office, field officer, submitted/rejected date, status
- Bulk approve with correct endpoint per deposit type (savings/FD/RD/current)
- Resolvers use forkJoin to fetch savings + current accounts in parallel
- Office name resolved via /offices map; field officer name taken directly from API response

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-05-22 14:02:40 +05:30
co-authored by Claude Sonnet 4.6
parent 3370a95235
commit df96be9a0e
11 changed files with 1009 additions and 1 deletions
@@ -0,0 +1,120 @@
<!--
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/.
-->
<div class="cv-tab-content">
@if (showData) {
<div class="cv-action-bar">
<mat-form-field appearance="outline" class="cv-search-field">
<mat-icon matPrefix>search</mat-icon>
<input
#filterInput
matInput
placeholder="Search by client or account"
(keyup)="applyFilter(filterInput.value)"
/>
</mat-form-field>
<div class="cv-action-buttons">
<button mat-stroked-button class="cv-btn cv-btn-approve" (click)="approveAccounts()">
<fa-icon icon="check" class="m-r-10"></fa-icon>Approve
</button>
</div>
</div>
<div class="table-container">
<table mat-table [dataSource]="dataSource" class="modern-table">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef>
<mat-checkbox
(change)="$event ? masterToggle() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()"
[aria-label]="checkboxLabel()"
></mat-checkbox>
</th>
<td mat-cell *matCellDef="let row">
<mat-checkbox
(click)="$event.stopPropagation()"
(change)="$event ? selection.toggle(row) : null"
[checked]="selection.isSelected(row)"
[aria-label]="checkboxLabel(row)"
></mat-checkbox>
</td>
</ng-container>
<ng-container matColumnDef="accountNo">
<th mat-header-cell *matHeaderCellDef>Account No</th>
<td mat-cell *matCellDef="let account" class="cell-mono">{{ account.accountNo }}</td>
</ng-container>
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef>Client Name</th>
<td mat-cell *matCellDef="let account">{{ account.clientName }}</td>
</ng-container>
<ng-container matColumnDef="productName">
<th mat-header-cell *matHeaderCellDef>Product</th>
<td mat-cell *matCellDef="let account">{{ account.savingsProductName || account.productName }}</td>
</ng-container>
<ng-container matColumnDef="accountType">
<th mat-header-cell *matHeaderCellDef>Type</th>
<td mat-cell *matCellDef="let account">
<span
class="type-badge"
[class.type-badge--current]="account.accountType === 'Current'"
[class.type-badge--fd]="account.accountType === 'Fixed Deposit'"
[class.type-badge--rd]="account.accountType === 'Recurring Deposit'"
>
{{ account.accountType }}
</span>
</td>
</ng-container>
<ng-container matColumnDef="officeName">
<th mat-header-cell *matHeaderCellDef>Office</th>
<td mat-cell *matCellDef="let account">{{ account.officeName }}</td>
</ng-container>
<ng-container matColumnDef="fieldOfficerName">
<th mat-header-cell *matHeaderCellDef>Field Officer</th>
<td mat-cell *matCellDef="let account">{{ account.fieldOfficerName || '—' }}</td>
</ng-container>
<ng-container matColumnDef="submittedOnDate">
<th mat-header-cell *matHeaderCellDef>Submitted On</th>
<td mat-cell *matCellDef="let account">
{{ formatDate(account.timeline?.submittedOnDate) }}
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let account">
<span class="status-badge status-badge--pending">{{ account.status?.value }}</span>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
(click)="selection.toggle(row)"
class="table-row"
></tr>
</table>
</div>
}
@if (!showData) {
<div class="empty-state">
<i class="fas fa-clock empty-icon"></i>
<p class="empty-title">No pending accounts</p>
<p class="empty-text">There are no savings accounts awaiting approval at this time.</p>
</div>
}
</div>
@@ -0,0 +1,171 @@
/**
* 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/.
*/
$brand: #1353a0;
$border: #e2e8f0;
$text-muted: #64748b;
.cv-tab-content {
padding: 16px 0;
}
.cv-action-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.cv-search-field {
flex: 1;
min-width: 200px;
max-width: 320px;
::ng-deep {
.mat-mdc-form-field-flex {
height: 36px;
}
.mat-mdc-form-field-infix {
padding: 6px 0;
min-height: 36px;
}
.mat-mdc-form-field-subscript-wrapper {
height: 0;
min-height: 0;
}
.mdc-notched-outline {
.mdc-notched-outline__leading {
border-radius: 8px 0 0 8px;
}
.mdc-notched-outline__trailing {
border-radius: 0 8px 8px 0;
}
}
.mat-mdc-form-field-icon-prefix {
padding: 0 6px 0 8px;
color: $text-muted;
font-size: 16px;
}
input {
font-size: 13px;
}
}
}
.cv-action-buttons {
display: flex;
gap: 8px;
margin-left: auto;
flex-shrink: 0;
}
.cv-btn {
font-size: 12px;
font-weight: 600;
border-radius: 8px !important;
height: 36px;
padding: 0 16px;
font-family: 'DM Sans', Roboto, sans-serif;
fa-icon {
margin-right: 4px;
font-size: 11px;
}
}
.cv-btn-approve {
border-color: #16a34a;
color: #16a34a;
&:hover {
background: #f0fdf4;
}
}
.type-badge {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
background: #dbeafe;
color: #1e40af;
&--current {
background: #ede9fe;
color: #5b21b6;
}
&--fd {
background: #fef3c7;
color: #92400e;
}
&--rd {
background: #d1fae5;
color: #065f46;
}
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
background: #fef9c3;
color: #854d0e;
&--pending {
background: #fef9c3;
color: #854d0e;
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 24px;
background: #fff;
border: 1px dashed $border;
border-radius: 14px;
text-align: center;
margin: 16px 0;
.empty-icon {
font-size: 42px;
color: #cbd5e1;
margin-bottom: 14px;
}
.empty-title {
margin: 0 0 4px;
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
}
.empty-text {
margin: 0;
font-size: 12.5px;
color: $text-muted;
max-width: 380px;
line-height: 1.5;
}
}
@@ -0,0 +1,178 @@
/**
* 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, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { SelectionModel } from '@angular/cdk/collections';
import {
MatTableDataSource,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
} from '@angular/material/table';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatCheckbox } from '@angular/material/checkbox';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { TasksService } from '../../tasks.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
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';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
selector: 'mifosx-pending-account',
templateUrl: './pending-account.component.html',
styleUrls: ['./pending-account.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCheckbox,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatIcon
]
})
export class PendingAccountComponent {
private route = inject(ActivatedRoute);
private router = inject(Router);
private dialog = inject(MatDialog);
private dateUtils = inject(Dates);
private settingsService = inject(SettingsService);
private tasksService = inject(TasksService);
dataSource: MatTableDataSource<any>;
selection: SelectionModel<any>;
showData = false;
displayedColumns: string[] = [
'select',
'accountNo',
'clientName',
'productName',
'accountType',
'officeName',
'fieldOfficerName',
'submittedOnDate',
'status'
];
constructor() {
this.route.data.subscribe((data: { pendingAccountsData: any }) => {
// status 100 = submitted and pending approval only; exclude active/approved/etc.
const items = (data.pendingAccountsData?.pageItems || []).filter((a: any) => a.status?.id === 100);
this.showData = items.length > 0;
this.dataSource = new MatTableDataSource(items);
this.selection = new SelectionModel(true, []);
});
}
isAllSelected() {
return this.selection.selected.length === this.dataSource.data.length;
}
masterToggle() {
this.isAllSelected() ? this.selection.clear() : this.dataSource.data.forEach((row) => this.selection.select(row));
}
checkboxLabel(row?: any): string {
if (!row) return `${this.isAllSelected() ? 'deselect' : 'select'} all`;
return `${this.selection.isSelected(row) ? 'deselect' : 'select'} row`;
}
applyFilter(filterValue: string = '') {
this.dataSource.filter = filterValue.trim().toLowerCase();
}
approveAccounts() {
const formfields: FormfieldBase[] = [
new DatepickerBase({
controlName: 'approvedOnDate',
label: 'Approved On Date',
value: new Date(),
type: 'datetime-local',
required: true
})
];
const dialogRef = this.dialog.open(FormDialogComponent, {
data: {
title: 'Enter Approval Date',
layout: { addButtonText: 'Confirm' },
formfields
}
});
dialogRef.afterClosed().subscribe((response: any) => {
if (response?.data) {
this.bulkApprove(response.data);
}
});
}
private bulkApprove(submittedData: any) {
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
const approvedOnDate = this.dateUtils.formatDate(submittedData.value.approvedOnDate, dateFormat);
const payload = { approvedOnDate, dateFormat, locale };
const selected = this.selection.selected;
let done = 0;
selected.forEach((account: any) => {
const depositTypeId = account.depositType?.id;
let approve$;
if (account.accountType === 'Current') {
approve$ = this.tasksService.approveCurrentAccount(account.id, payload);
} else if (depositTypeId === 200) {
approve$ = this.tasksService.approveFDAccount(account.id, payload);
} else if (depositTypeId === 300) {
approve$ = this.tasksService.approveRDAccount(account.id, payload);
} else {
approve$ = this.tasksService.approveSavingsAccount(account.id, payload);
}
approve$.subscribe(() => {
done++;
if (done === selected.length) {
this.reload();
}
});
});
}
formatDate(date: any): string {
if (!date) return '';
if (Array.isArray(date)) {
const d = new Date(date[0], date[1] - 1, date[2]);
return d.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
}
return date;
}
reload() {
const url = this.router.url;
this.router
.navigateByUrl('/checker-inbox-and-tasks', { skipLocationChange: true })
.then(() => this.router.navigate([url]));
}
}
@@ -0,0 +1,98 @@
<!--
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/.
-->
<div class="cv-tab-content">
@if (showData) {
<div class="cv-action-bar">
<mat-form-field appearance="outline" class="cv-search-field">
<mat-icon matPrefix>search</mat-icon>
<input
#filterInput
matInput
placeholder="Search by client or account"
(keyup)="applyFilter(filterInput.value)"
/>
</mat-form-field>
</div>
<div class="table-container">
<table mat-table [dataSource]="dataSource" class="modern-table">
<ng-container matColumnDef="accountNo">
<th mat-header-cell *matHeaderCellDef>Account No</th>
<td mat-cell *matCellDef="let account" class="cell-mono">{{ account.accountNo }}</td>
</ng-container>
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef>Client Name</th>
<td mat-cell *matCellDef="let account">{{ account.clientName }}</td>
</ng-container>
<ng-container matColumnDef="productName">
<th mat-header-cell *matHeaderCellDef>Product</th>
<td mat-cell *matCellDef="let account">{{ account.savingsProductName || account.productName }}</td>
</ng-container>
<ng-container matColumnDef="accountType">
<th mat-header-cell *matHeaderCellDef>Type</th>
<td mat-cell *matCellDef="let account">
<span
class="type-badge"
[class.type-badge--current]="account.accountType === 'Current'"
[class.type-badge--fd]="account.accountType === 'Fixed Deposit'"
[class.type-badge--rd]="account.accountType === 'Recurring Deposit'"
>
{{ account.accountType }}
</span>
</td>
</ng-container>
<ng-container matColumnDef="officeName">
<th mat-header-cell *matHeaderCellDef>Office</th>
<td mat-cell *matCellDef="let account">{{ account.officeName }}</td>
</ng-container>
<ng-container matColumnDef="fieldOfficerName">
<th mat-header-cell *matHeaderCellDef>Field Officer</th>
<td mat-cell *matCellDef="let account">{{ account.fieldOfficerName || '—' }}</td>
</ng-container>
<ng-container matColumnDef="submittedOnDate">
<th mat-header-cell *matHeaderCellDef>Submitted On</th>
<td mat-cell *matCellDef="let account">
{{ formatDate(account.timeline?.submittedOnDate) }}
</td>
</ng-container>
<ng-container matColumnDef="rejectedOnDate">
<th mat-header-cell *matHeaderCellDef>Rejected On</th>
<td mat-cell *matCellDef="let account">
{{ formatDate(account.timeline?.rejectedOnDate) }}
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let account">
<span class="status-badge status-badge--rejected">{{ account.status?.value }}</span>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row"></tr>
</table>
</div>
}
@if (!showData) {
<div class="empty-state">
<i class="fas fa-ban empty-icon"></i>
<p class="empty-title">No rejected accounts</p>
<p class="empty-text">There are no rejected savings accounts to display.</p>
</div>
}
</div>
@@ -0,0 +1,138 @@
/**
* 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/.
*/
$border: #e2e8f0;
$text-muted: #64748b;
.cv-tab-content {
padding: 16px 0;
}
.cv-action-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.cv-search-field {
flex: 1;
min-width: 200px;
max-width: 320px;
::ng-deep {
.mat-mdc-form-field-flex {
height: 36px;
}
.mat-mdc-form-field-infix {
padding: 6px 0;
min-height: 36px;
}
.mat-mdc-form-field-subscript-wrapper {
height: 0;
min-height: 0;
}
.mdc-notched-outline {
.mdc-notched-outline__leading {
border-radius: 8px 0 0 8px;
}
.mdc-notched-outline__trailing {
border-radius: 0 8px 8px 0;
}
}
.mat-mdc-form-field-icon-prefix {
padding: 0 6px 0 8px;
color: $text-muted;
font-size: 16px;
}
input {
font-size: 13px;
}
}
}
.type-badge {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
background: #dbeafe;
color: #1e40af;
&--current {
background: #ede9fe;
color: #5b21b6;
}
&--fd {
background: #fef3c7;
color: #92400e;
}
&--rd {
background: #d1fae5;
color: #065f46;
}
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
&--rejected {
background: #fee2e2;
color: #991b1b;
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 24px;
background: #fff;
border: 1px dashed $border;
border-radius: 14px;
text-align: center;
margin: 16px 0;
.empty-icon {
font-size: 42px;
color: #cbd5e1;
margin-bottom: 14px;
}
.empty-title {
margin: 0 0 4px;
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
}
.empty-text {
margin: 0;
font-size: 12.5px;
color: $text-muted;
max-width: 380px;
line-height: 1.5;
}
}
@@ -0,0 +1,87 @@
/**
* 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, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import {
MatTableDataSource,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
} from '@angular/material/table';
import { MatIcon } from '@angular/material/icon';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
selector: 'mifosx-rejected-account',
templateUrl: './rejected-account.component.html',
styleUrls: ['./rejected-account.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatIcon
]
})
export class RejectedAccountComponent {
private route = inject(ActivatedRoute);
dataSource: MatTableDataSource<any>;
showData = false;
displayedColumns: string[] = [
'accountNo',
'clientName',
'productName',
'accountType',
'officeName',
'fieldOfficerName',
'submittedOnDate',
'rejectedOnDate',
'status'
];
constructor() {
this.route.data.subscribe((data: { rejectedAccountsData: any }) => {
// status 500 = rejected only; strip out any non-rejected records
const items = (data.rejectedAccountsData?.pageItems || []).filter((a: any) => a.status?.id === 500);
this.showData = items.length > 0;
this.dataSource = new MatTableDataSource(items);
});
}
applyFilter(filterValue: string = '') {
this.dataSource.filter = filterValue.trim().toLowerCase();
}
formatDate(date: any): string {
if (!date) return '';
if (Array.isArray(date)) {
const d = new Date(date[0], date[1] - 1, date[2]);
return d.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
}
return date;
}
}