feat(tasks): backend-driven pagination, search and date filters for Client Approval

Client Approval previously fetched up to 1000 pending clients via a route
resolver and did name-search/sorting client-side (the search box was
actually dead — it set a MatTableDataSource filter that the grouped-table
view never read). Reworked to mirror PendingClientEditsComponent: real
mat-paginator against /clients (offset/limit), server-side displayName
search, orderBy=submittedOnDate for latest-first, and All/Today/Yesterday
presets backed by the new fromDate/toDate params. Dropped the now-unused
GetGroupedClientsData resolver.

Also: Account Number column -> Client ID (element.id), disabled the
Approve button with a "N selected" indicator when nothing's checked, and
a redesigned action bar / empty-state (context-aware message for
zero-results-from-search vs. zero-results-from-date-filter vs. truly
nothing pending) — plus the plain-div search box and matched heights
across chips/search/button, replacing an earlier mat-form-field version
that Material's own outlined-field box model kept misaligning against the
pill buttons.
This commit is contained in:
2026-07-30 10:31:55 +05:30
parent 4ea833ff49
commit 1efb520009
6 changed files with 417 additions and 158 deletions
@@ -6,29 +6,73 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/. file, You can obtain one at http://mozilla.org/MPL/2.0/.
--> -->
@if (showData) { <div class="cv-tab-content">
<div class="cv-tab-content"> <div class="cv-action-bar">
<div class="cv-action-bar"> <div class="cv-search-box">
<mat-form-field appearance="outline" class="cv-search-field"> <fa-icon icon="search"></fa-icon>
<mat-icon matPrefix>search</mat-icon> <input
<input #filterInput
#filterInput type="text"
matInput placeholder="{{ 'labels.text.Filter by name' | translate }}"
placeholder="{{ 'labels.text.Filter by name' | translate }}" (keyup.enter)="applyFilter(filterInput.value)"
(keyup)="applyFilter(filterInput.value)" />
/>
</mat-form-field>
<div class="cv-action-buttons">
<button
mat-stroked-button
class="cv-btn cv-btn-approve"
*mifosxHasPermission="'ACTIVATE_CLIENT'"
(click)="approveClients()"
>
<fa-icon icon="check" class="m-r-10"></fa-icon>{{ 'labels.buttons.Approve' | translate }}
</button>
</div>
</div> </div>
<div class="cv-quick-filters">
@for (p of datePresets; track p.key) {
<button
type="button"
class="cv-chip"
[class.cv-chip--active]="activePreset === p.key"
(click)="applyPreset(p.key)"
>
{{ p.label }}
</button>
}
</div>
<div class="cv-action-buttons">
@if (selection.selected.length > 0) {
<span class="cv-selected-count">{{ selection.selected.length }} selected</span>
}
<button
mat-stroked-button
class="cv-btn cv-btn-approve"
*mifosxHasPermission="'ACTIVATE_CLIENT'"
[disabled]="!selection.hasValue()"
(click)="approveClients()"
>
<fa-icon icon="check" class="m-r-10"></fa-icon>{{ 'labels.buttons.Approve' | translate }}
</button>
</div>
</div>
@if (loading) {
<div class="cv-empty">
<div class="cv-spinner"></div>
<p class="cv-empty__text">Loading pending clients&hellip;</p>
</div>
} @else if (loaded && !showData) {
<div class="cv-empty">
<div class="cv-empty__badge">
<fa-icon icon="user-check"></fa-icon>
</div>
@if (searchTerm) {
<p class="cv-empty__title">No matching clients</p>
<p class="cv-empty__text">
No pending client matches &ldquo;{{ searchTerm }}&rdquo;. Try
<a class="cv-empty__link" (click)="applyFilter(''); filterInput.value = ''">clearing the search</a>.
</p>
} @else if (activePreset !== 'all') {
<p class="cv-empty__title">No requests in this range</p>
<p class="cv-empty__text">
No client requests were submitted in this range. Try
<a class="cv-empty__link" (click)="applyPreset('all')">All requests</a> instead.
</p>
} @else {
<p class="cv-empty__title">{{ 'labels.text.No pending loans for approval' | translate }}</p>
<p class="cv-empty__text">Every client request has been reviewed — nothing waiting on you right now.</p>
}
</div>
} @else {
@for (client of groupedClients | keyvalue; track client) { @for (client of groupedClients | keyvalue; track client) {
<h3 class="cv-group-title">{{ client.key }}</h3> <h3 class="cv-group-title">{{ client.key }}</h3>
@if (client.value.length > 0) { @if (client.value.length > 0) {
@@ -60,8 +104,8 @@
{{ element.displayName }} {{ element.displayName }}
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="accountNumber"> <ng-container matColumnDef="clientId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account Number' | translate }}</th> <th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Client ID' | translate }}</th>
<td <td
mat-cell mat-cell
*matCellDef="let element" *matCellDef="let element"
@@ -69,7 +113,7 @@
[routerLink]="['../../clients', element.id]" [routerLink]="['../../clients', element.id]"
style="cursor: pointer" style="cursor: pointer"
> >
{{ element.accountNo }} {{ element.id }}
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="staff"> <ng-container matColumnDef="staff">
@@ -94,12 +138,15 @@
</div> </div>
} }
} }
</div>
}
@if (!showData) { <mat-paginator
<div class="empty-state"> class="cv-paginator"
<i class="fas fa-user-check empty-icon"></i> [length]="totalRecords"
<p class="empty-text">{{ 'labels.text.No pending loans for approval' | translate }}</p> [pageSize]="pageSize"
</div> [pageSizeOptions]="pageSizeOptions"
} [pageIndex]="pageIndex"
showFirstLastButtons
(page)="onPageChange($event)"
></mat-paginator>
}
</div>
@@ -9,9 +9,14 @@
@use '../../../shared/styles/list-layout' as *; @use '../../../shared/styles/list-layout' as *;
$brand: #1353a0; $brand: #1353a0;
$brand-tint: #eaf1fb;
$success: #16a34a;
$success-tint: #ecfdf5;
$border: #e2e8f0; $border: #e2e8f0;
$text: #0f172a; $text: #0f172a;
$text-muted: #64748b; $text-muted: #64748b;
$surface: #fff;
$surface-muted: #f8fafc;
.cv-tab-content { .cv-tab-content {
padding: 16px 0; padding: 16px 0;
@@ -20,93 +25,252 @@ $text-muted: #64748b;
.cv-action-bar { .cv-action-bar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
margin-bottom: 16px; margin-bottom: 20px;
flex-wrap: wrap; flex-wrap: wrap;
padding: 10px 14px;
background: $surface;
border: 1px solid $border;
border-radius: 12px;
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
} }
.cv-search-field { .cv-search-box {
display: inline-flex;
align-items: center;
gap: 8px;
flex: 1; flex: 1;
min-width: 200px; min-width: 200px;
max-width: 320px; max-width: 320px;
height: 34px;
padding: 0 12px;
background: $surface-muted;
border: 1px solid $border;
border-radius: 8px;
transition:
background 0.15s,
border-color 0.15s,
box-shadow 0.15s;
::ng-deep { fa-icon {
.mat-mdc-form-field-flex { color: $text-muted;
height: 32px; font-size: 13px;
flex-shrink: 0;
}
input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
font-size: 13px;
font-family: inherit;
color: $text;
&::placeholder {
color: #94a3b8;
} }
}
.mat-mdc-form-field-infix { &:focus-within {
padding: 4px 0; background: $surface;
min-height: 32px; border-color: $brand;
} box-shadow: 0 0 0 2px rgb(19 83 160 / 18%);
}
}
.mat-mdc-form-field-subscript-wrapper { .cv-quick-filters {
height: 0; display: flex;
min-height: 0; align-items: center;
} gap: 6px;
flex-shrink: 0;
}
.mdc-notched-outline { .cv-chip {
.mdc-notched-outline__leading { display: inline-flex;
border-radius: 8px 0 0 8px; align-items: center;
} font-size: 12px;
font-weight: 500;
font-family: 'DM Sans', Roboto, sans-serif;
color: $text-muted;
background: $surface;
border: 1px solid $border;
border-radius: 17px;
height: 34px;
padding: 0 14px;
cursor: pointer;
transition:
background 0.15s,
color 0.15s,
border-color 0.15s,
box-shadow 0.15s;
.mdc-notched-outline__trailing { &:hover {
border-radius: 0 8px 8px 0; background: $surface-muted;
} border-color: #cbd5e1;
} }
.mat-mdc-form-field-icon-prefix { &--active {
padding: 0 6px 0 8px; background: $brand;
color: $text-muted; border-color: $brand;
} color: #fff;
box-shadow: 0 2px 6px rgb(19 83 160 / 30%);
input { &:hover {
font-size: 13px; background: $brand;
} }
} }
} }
.cv-action-buttons { .cv-action-buttons {
display: flex; display: flex;
gap: 8px; align-items: center;
gap: 10px;
margin-left: auto; margin-left: auto;
flex-shrink: 0; flex-shrink: 0;
} }
.cv-selected-count {
font-size: 12px;
font-weight: 600;
color: $brand;
background: $brand-tint;
border-radius: 14px;
padding: 6px 12px;
white-space: nowrap;
}
.cv-btn { .cv-btn {
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 600;
border-radius: 8px; border-radius: 8px;
height: 32px; height: 34px;
padding: 0 14px; padding: 0 16px;
font-family: 'DM Sans', Roboto, sans-serif; font-family: 'DM Sans', Roboto, sans-serif;
letter-spacing: 0.1px;
fa-icon { fa-icon {
margin-right: 4px; margin-right: 6px;
font-size: 11px; font-size: 11px;
} }
} }
.cv-btn-approve { .cv-btn-approve {
border-color: #16a34a; border-color: $success;
color: #16a34a; color: $success;
&:hover { &:hover:not([disabled]) {
background: #f0fdf4; background: $success-tint;
}
&[disabled] {
opacity: 0.45;
} }
} }
.cv-group-title { .cv-group-title {
font-size: 14px; display: flex;
font-weight: 600; align-items: center;
color: $text; gap: 6px;
margin: 20px 0 8px; font-size: 13px;
padding-bottom: 4px; font-weight: 700;
letter-spacing: 0.2px;
text-transform: uppercase;
color: $text-muted;
margin: 22px 0 8px;
padding-bottom: 6px;
border-bottom: 1px solid #f1f5f9; border-bottom: 1px solid #f1f5f9;
&::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: $brand;
flex-shrink: 0;
}
} }
.cv-group-table { .cv-group-table {
margin-bottom: 12px; margin-bottom: 12px;
border-radius: 10px;
overflow: hidden;
border: 1px solid $border;
}
.cv-paginator {
margin-top: 8px;
background: transparent;
}
// ===== Empty / loading state — scoped locally so it doesn't touch the shared
// .empty-state used by every other checker-inbox tab. =====
.cv-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 56px 24px;
text-align: center;
background: $surface;
border: 1px solid $border;
border-radius: 12px;
}
.cv-empty__badge {
width: 56px;
height: 56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: $success-tint;
border: 1px solid #a7f3d0;
color: $success;
font-size: 20px;
margin-bottom: 16px;
}
.cv-empty__title {
font-size: 15px;
font-weight: 700;
color: $text;
margin: 0 0 6px;
}
.cv-empty__text {
font-size: 13px;
color: $text-muted;
margin: 0;
max-width: 360px;
line-height: 1.5;
}
.cv-empty__link {
color: $brand;
font-weight: 600;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.cv-spinner {
width: 28px;
height: 28px;
border-radius: 50%;
border: 3px solid $border;
border-top-color: $brand;
margin-bottom: 14px;
animation: cv-spin 0.7s linear infinite;
}
@keyframes cv-spin {
to {
transform: rotate(360deg);
}
} }
@media (width <= 768px) { @media (width <= 768px) {
@@ -121,5 +285,6 @@ $text-muted: #64748b;
.cv-action-buttons { .cv-action-buttons {
margin-left: 0; margin-left: 0;
justify-content: space-between;
} }
} }
@@ -8,11 +8,9 @@
/** Angular Imports */ /** Angular Imports */
import { Component, OnInit, inject } from '@angular/core'; import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { SelectionModel } from '@angular/cdk/collections'; import { SelectionModel } from '@angular/cdk/collections';
import * as _ from 'lodash'; import * as _ from 'lodash';
import { import {
MatTableDataSource,
MatTable, MatTable,
MatColumnDef, MatColumnDef,
MatHeaderCellDef, MatHeaderCellDef,
@@ -24,6 +22,7 @@ import {
MatRowDef, MatRowDef,
MatRow MatRow
} from '@angular/material/table'; } from '@angular/material/table';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
/** Dialog Imports */ /** Dialog Imports */
@@ -41,6 +40,12 @@ import { MatCheckbox } from '@angular/material/checkbox';
import { AccountsFilterPipe } from '../../../pipes/accounts-filter.pipe'; import { AccountsFilterPipe } from '../../../pipes/accounts-filter.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; 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({ @Component({
selector: 'mifosx-client-approval', selector: 'mifosx-client-approval',
templateUrl: './client-approval.component.html', templateUrl: './client-approval.component.html',
@@ -60,53 +65,122 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatRowDef, MatRowDef,
MatRow, MatRow,
KeyValuePipe, KeyValuePipe,
AccountsFilterPipe AccountsFilterPipe,
MatPaginatorModule
] ]
}) })
export class ClientApprovalComponent { export class ClientApprovalComponent implements OnInit {
private route = inject(ActivatedRoute);
private dialog = inject(MatDialog); private dialog = inject(MatDialog);
private dateUtils = inject(Dates); private dateUtils = inject(Dates);
private router = inject(Router);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private tasksService = inject(TasksService); private tasksService = inject(TasksService);
/** Grouped Clients Data */ /** Current page of pending clients (from the server), grouped by office for display */
groupedClients: any; groupedClients: any = {};
/** Checks to show the data */ /** True once the first page has loaded and it contains at least one client */
showData = false; 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 */ /** Batch Requests */
batchRequests: any[]; batchRequests: any[];
/** Datasource */
dataSource: MatTableDataSource<any>;
/** Row Selection Data */
selection: SelectionModel<any>;
/** Displayed Columns */ /** Displayed Columns */
displayedColumns: string[] = [ displayedColumns: string[] = [
'select', 'select',
'name', 'name',
'accountNumber', 'clientId',
'staff' 'staff'
]; ];
/** totalRecords = 0;
* Retrieves the grouped client data from `resolve`. pageSize = 20;
* @param {ActivatedRoute} route Activated Route. pageIndex = 0;
* @param {Dialog} dialog MatDialog. pageSizeOptions = [
* @param {Dates} dateUtils Date Utils. 10,
* @param {router} router Router. 20,
* @param {SettingsService} settingsService Settings Service. 50
* @param {TasksService} tasksService Tasks Service. ];
*/
constructor() { searchTerm = '';
this.route.data.subscribe((data: { groupedClientData: any }) => { activePreset = 'all';
this.groupedClients = _.groupBy(data.groupedClientData.pageItems, 'officeName'); readonly datePresets = [
if (Object.keys(this.groupedClients).length) { { key: 'all', label: 'All requests' },
this.showData = true; { key: 'today', label: 'Today' },
} { key: 'yesterday', label: 'Yesterday' }
this.dataSource = new MatTableDataSource(data.groupedClientData.pageItems); ];
this.selection = new SelectionModel(true, []); 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. */ /** Whether the number of selected elements matches the total number of rows. */
@@ -183,6 +257,7 @@ export class ClientApprovalComponent {
activatedAccounts++; activatedAccounts++;
responseEle.body = JSON.parse(responseEle.body); responseEle.body = JSON.parse(responseEle.body);
if (selectedAccounts === activatedAccounts) { if (selectedAccounts === activatedAccounts) {
this.selection.clear();
this.reload(); this.reload();
} }
} }
@@ -190,18 +265,10 @@ export class ClientApprovalComponent {
}); });
} }
applyFilter(filterValue: string = '') {
this.dataSource.filter = filterValue.trim().toLowerCase();
}
/** /**
* Refetches data for the component * Refetches the current page after a bulk approval.
* TODO: Replace by a custom reload component instead of hard-coded back-routing.
*/ */
reload() { reload() {
const url: string = this.router.url; this.load();
this.router
.navigateByUrl(`/checker-inbox-and-tasks`, { skipLocationChange: true })
.then(() => this.router.navigate([url]));
} }
} }
@@ -1,32 +0,0 @@
/**
* 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 { Injectable, inject } from '@angular/core';
/** rxjs Imports */
import { Observable } from 'rxjs';
/** Custom Services */
import { TasksService } from '../tasks.service';
/**
* Grouped Client Data data resolver.
*/
@Injectable()
export class GetGroupedClientsData {
private tasksService = inject(TasksService);
/**
* Returns the grouped client data.
* @returns {Observable<any>}
*/
resolve(): Observable<any> {
return this.tasksService.getGroupedClientsData();
}
}
+1 -6
View File
@@ -35,7 +35,6 @@ import { ViewCheckerInboxComponent } from './view-checker-inbox/view-checker-inb
/** Custom Resolvers */ /** Custom Resolvers */
import { GetMakerCheckers } from './common-resolvers/getmakercheckers.resolver'; import { GetMakerCheckers } from './common-resolvers/getmakercheckers.resolver';
import { GetGroupedClientsData } from './common-resolvers/getGroupedClientsData.resolver';
import { GetOffices } from './common-resolvers/getOffices.resolver'; import { GetOffices } from './common-resolvers/getOffices.resolver';
import { GetLoansToBeApproved } from './common-resolvers/getLoansToBeApproved.resolver'; import { GetLoansToBeApproved } from './common-resolvers/getLoansToBeApproved.resolver';
import { GetLoansToBeDisbursed } from './common-resolvers/getLoansToBeDisbursed.resolver'; import { GetLoansToBeDisbursed } from './common-resolvers/getLoansToBeDisbursed.resolver';
@@ -70,10 +69,7 @@ const routes: Routes = [
{ {
path: 'client-approval', path: 'client-approval',
component: ClientApprovalComponent, component: ClientApprovalComponent,
data: { title: 'Client Approval' }, data: { title: 'Client Approval' }
resolve: {
groupedClientData: GetGroupedClientsData
}
}, },
{ {
path: 'loan-approval', path: 'loan-approval',
@@ -225,7 +221,6 @@ const routes: Routes = [
exports: [RouterModule], exports: [RouterModule],
providers: [ providers: [
GetMakerCheckers, GetMakerCheckers,
GetGroupedClientsData,
GetOffices, GetOffices,
GetLoansToBeApproved, GetLoansToBeApproved,
GetLoansToBeDisbursed, GetLoansToBeDisbursed,
+20 -3
View File
@@ -48,10 +48,27 @@ export class TasksService {
} }
/** /**
* Get Grouped Clients Data * Get pending clients awaiting approval — pagination, name search and submitted-date
* filtering all happen server-side via the standard /clients list API (offset/limit,
* displayName, fromDate/toDate on submittedon_date), mirroring
* ClientsService.getAllPendingClientEdits.
*/ */
getGroupedClientsData(): Observable<any> { getGroupedClientsData(
const httpParams = new HttpParams().set('limit', '1000').set('status', 'pending'); displayName?: string,
fromDate?: string,
toDate?: string,
offset = 0,
limit = 20
): Observable<any> {
let httpParams = new HttpParams()
.set('status', 'pending')
.set('orderBy', 'submittedOnDate')
.set('sortOrder', 'DESC')
.set('offset', offset)
.set('limit', limit);
if (displayName) httpParams = httpParams.set('displayName', displayName);
if (fromDate) httpParams = httpParams.set('fromDate', fromDate);
if (toDate) httpParams = httpParams.set('toDate', toDate);
return this.http.get('/clients', { params: httpParams }); return this.http.get('/clients', { params: httpParams });
} }