diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/client-approval/client-approval.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/client-approval/client-approval.component.html index 7c79d01ee..53b446b53 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/client-approval/client-approval.component.html +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/client-approval/client-approval.component.html @@ -6,29 +6,73 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -@if (showData) { -
-
- - search - - -
- -
+
+
+ +
+ @for (p of datePresets; track p.key) { + + } +
+
+ @if (selection.selected.length > 0) { + {{ selection.selected.length }} selected + } + +
+
+ + @if (loading) { +
+
+

Loading pending clients…

+
+ } @else if (loaded && !showData) { +
+
+ +
+ @if (searchTerm) { +

No matching clients

+

+ No pending client matches “{{ searchTerm }}”. Try + clearing the search. +

+ } @else if (activePreset !== 'all') { +

No requests in this range

+

+ No client requests were submitted in this range. Try + All requests instead. +

+ } @else { +

{{ 'labels.text.No pending loans for approval' | translate }}

+

Every client request has been reviewed — nothing waiting on you right now.

+ } +
+ } @else { @for (client of groupedClients | keyvalue; track client) {

{{ client.key }}

@if (client.value.length > 0) { @@ -60,8 +104,8 @@ {{ element.displayName }} - - {{ 'labels.inputs.Account Number' | translate }} + + {{ 'labels.inputs.Client ID' | translate }} - {{ element.accountNo }} + {{ element.id }} @@ -94,12 +138,15 @@
} } -
-} -@if (!showData) { -
- -

{{ 'labels.text.No pending loans for approval' | translate }}

-
-} + + } +
diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/client-approval/client-approval.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/client-approval/client-approval.component.scss index 80e3ad1f3..197b021d7 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/client-approval/client-approval.component.scss +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/client-approval/client-approval.component.scss @@ -9,9 +9,14 @@ @use '../../../shared/styles/list-layout' as *; $brand: #1353a0; +$brand-tint: #eaf1fb; +$success: #16a34a; +$success-tint: #ecfdf5; $border: #e2e8f0; $text: #0f172a; $text-muted: #64748b; +$surface: #fff; +$surface-muted: #f8fafc; .cv-tab-content { padding: 16px 0; @@ -20,93 +25,252 @@ $text-muted: #64748b; .cv-action-bar { display: flex; align-items: center; - gap: 8px; - margin-bottom: 16px; + gap: 10px; + margin-bottom: 20px; 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; min-width: 200px; 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 { - .mat-mdc-form-field-flex { - height: 32px; + fa-icon { + color: $text-muted; + 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 { - padding: 4px 0; - min-height: 32px; - } + &:focus-within { + background: $surface; + border-color: $brand; + box-shadow: 0 0 0 2px rgb(19 83 160 / 18%); + } +} - .mat-mdc-form-field-subscript-wrapper { - height: 0; - min-height: 0; - } +.cv-quick-filters { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} - .mdc-notched-outline { - .mdc-notched-outline__leading { - border-radius: 8px 0 0 8px; - } +.cv-chip { + display: inline-flex; + 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 { - border-radius: 0 8px 8px 0; - } - } + &:hover { + background: $surface-muted; + border-color: #cbd5e1; + } - .mat-mdc-form-field-icon-prefix { - padding: 0 6px 0 8px; - color: $text-muted; - } + &--active { + background: $brand; + border-color: $brand; + color: #fff; + box-shadow: 0 2px 6px rgb(19 83 160 / 30%); - input { - font-size: 13px; + &:hover { + background: $brand; } } } .cv-action-buttons { display: flex; - gap: 8px; + align-items: center; + gap: 10px; margin-left: auto; 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 { font-size: 12px; - font-weight: 500; + font-weight: 600; border-radius: 8px; - height: 32px; - padding: 0 14px; + height: 34px; + padding: 0 16px; font-family: 'DM Sans', Roboto, sans-serif; + letter-spacing: 0.1px; fa-icon { - margin-right: 4px; + margin-right: 6px; font-size: 11px; } } .cv-btn-approve { - border-color: #16a34a; - color: #16a34a; + border-color: $success; + color: $success; - &:hover { - background: #f0fdf4; + &:hover:not([disabled]) { + background: $success-tint; + } + + &[disabled] { + opacity: 0.45; } } .cv-group-title { - font-size: 14px; - font-weight: 600; - color: $text; - margin: 20px 0 8px; - padding-bottom: 4px; + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + 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; + + &::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: $brand; + flex-shrink: 0; + } } .cv-group-table { 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) { @@ -121,5 +285,6 @@ $text-muted: #64748b; .cv-action-buttons { margin-left: 0; + justify-content: space-between; } } diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/client-approval/client-approval.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/client-approval/client-approval.component.ts index 5c91d6a51..f44b7b943 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/client-approval/client-approval.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/client-approval/client-approval.component.ts @@ -8,11 +8,9 @@ /** Angular Imports */ import { Component, OnInit, inject } from '@angular/core'; -import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { SelectionModel } from '@angular/cdk/collections'; import * as _ from 'lodash'; import { - MatTableDataSource, MatTable, MatColumnDef, MatHeaderCellDef, @@ -24,6 +22,7 @@ import { MatRowDef, MatRow } from '@angular/material/table'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; import { MatDialog } from '@angular/material/dialog'; /** Dialog Imports */ @@ -41,6 +40,12 @@ 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', @@ -60,53 +65,122 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; MatRowDef, MatRow, KeyValuePipe, - AccountsFilterPipe + AccountsFilterPipe, + MatPaginatorModule ] }) -export class ClientApprovalComponent { - private route = inject(ActivatedRoute); +export class ClientApprovalComponent implements OnInit { private dialog = inject(MatDialog); private dateUtils = inject(Dates); - private router = inject(Router); private settingsService = inject(SettingsService); private tasksService = inject(TasksService); - /** Grouped Clients Data */ - groupedClients: any; - /** Checks to show the data */ + /** 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[]; - /** Datasource */ - dataSource: MatTableDataSource; - /** Row Selection Data */ - selection: SelectionModel; /** Displayed Columns */ displayedColumns: string[] = [ 'select', 'name', - 'accountNumber', + 'clientId', 'staff' ]; - /** - * Retrieves the grouped client data from `resolve`. - * @param {ActivatedRoute} route Activated Route. - * @param {Dialog} dialog MatDialog. - * @param {Dates} dateUtils Date Utils. - * @param {router} router Router. - * @param {SettingsService} settingsService Settings Service. - * @param {TasksService} tasksService Tasks Service. - */ - constructor() { - this.route.data.subscribe((data: { groupedClientData: any }) => { - this.groupedClients = _.groupBy(data.groupedClientData.pageItems, 'officeName'); - if (Object.keys(this.groupedClients).length) { - this.showData = true; - } - this.dataSource = new MatTableDataSource(data.groupedClientData.pageItems); - this.selection = new SelectionModel(true, []); - }); + 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. */ @@ -183,6 +257,7 @@ export class ClientApprovalComponent { activatedAccounts++; responseEle.body = JSON.parse(responseEle.body); if (selectedAccounts === activatedAccounts) { + this.selection.clear(); this.reload(); } } @@ -190,18 +265,10 @@ export class ClientApprovalComponent { }); } - applyFilter(filterValue: string = '') { - this.dataSource.filter = filterValue.trim().toLowerCase(); - } - /** - * Refetches data for the component - * TODO: Replace by a custom reload component instead of hard-coded back-routing. + * Refetches the current page after a bulk approval. */ reload() { - const url: string = this.router.url; - this.router - .navigateByUrl(`/checker-inbox-and-tasks`, { skipLocationChange: true }) - .then(() => this.router.navigate([url])); + this.load(); } } diff --git a/src/app/tasks/common-resolvers/getGroupedClientsData.resolver.ts b/src/app/tasks/common-resolvers/getGroupedClientsData.resolver.ts deleted file mode 100644 index 74535c741..000000000 --- a/src/app/tasks/common-resolvers/getGroupedClientsData.resolver.ts +++ /dev/null @@ -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} - */ - resolve(): Observable { - return this.tasksService.getGroupedClientsData(); - } -} diff --git a/src/app/tasks/tasks-routing.module.ts b/src/app/tasks/tasks-routing.module.ts index 7f79974d0..d5e7ee848 100644 --- a/src/app/tasks/tasks-routing.module.ts +++ b/src/app/tasks/tasks-routing.module.ts @@ -35,7 +35,6 @@ import { ViewCheckerInboxComponent } from './view-checker-inbox/view-checker-inb /** Custom Resolvers */ import { GetMakerCheckers } from './common-resolvers/getmakercheckers.resolver'; -import { GetGroupedClientsData } from './common-resolvers/getGroupedClientsData.resolver'; import { GetOffices } from './common-resolvers/getOffices.resolver'; import { GetLoansToBeApproved } from './common-resolvers/getLoansToBeApproved.resolver'; import { GetLoansToBeDisbursed } from './common-resolvers/getLoansToBeDisbursed.resolver'; @@ -70,10 +69,7 @@ const routes: Routes = [ { path: 'client-approval', component: ClientApprovalComponent, - data: { title: 'Client Approval' }, - resolve: { - groupedClientData: GetGroupedClientsData - } + data: { title: 'Client Approval' } }, { path: 'loan-approval', @@ -225,7 +221,6 @@ const routes: Routes = [ exports: [RouterModule], providers: [ GetMakerCheckers, - GetGroupedClientsData, GetOffices, GetLoansToBeApproved, GetLoansToBeDisbursed, diff --git a/src/app/tasks/tasks.service.ts b/src/app/tasks/tasks.service.ts index 294ce91de..5f17d6809 100644 --- a/src/app/tasks/tasks.service.ts +++ b/src/app/tasks/tasks.service.ts @@ -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 { - const httpParams = new HttpParams().set('limit', '1000').set('status', 'pending'); + getGroupedClientsData( + displayName?: string, + fromDate?: string, + toDate?: string, + offset = 0, + limit = 20 + ): Observable { + 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 }); }