diff --git a/src/app/core/progress-bar/no-progress.token.ts b/src/app/core/progress-bar/no-progress.token.ts new file mode 100644 index 000000000..a4159c292 --- /dev/null +++ b/src/app/core/progress-bar/no-progress.token.ts @@ -0,0 +1,16 @@ +/** + * 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 { HttpContextToken } from '@angular/common/http'; + +/** + * Per-request opt-out of the top progress bar. Set via `{ context: new HttpContext().set(NO_PROGRESS_BAR, true) }` + * on background calls the user did not ask for (such as the Approvals badge listening for changes), which would + * otherwise keep the bar running or make it flash on a screen that has not changed. + */ +export const NO_PROGRESS_BAR = new HttpContextToken(() => false); diff --git a/src/app/core/progress-bar/progress.interceptor.ts b/src/app/core/progress-bar/progress.interceptor.ts index 482b4ad04..7a09d70ff 100644 --- a/src/app/core/progress-bar/progress.interceptor.ts +++ b/src/app/core/progress-bar/progress.interceptor.ts @@ -16,6 +16,7 @@ import { finalize } from 'rxjs/operators'; /** Custom Services */ import { ProgressBarService } from './progress-bar.service'; +import { NO_PROGRESS_BAR } from './no-progress.token'; /** * Http Request interceptor to start/stop loading the progress bar. @@ -29,6 +30,9 @@ export class ProgressInterceptor implements HttpInterceptor { * and stop when a response or error is received. */ intercept(request: HttpRequest, next: HttpHandler): Observable> { + if (request.context.get(NO_PROGRESS_BAR)) { + return next.handle(request); + } this.progressBarService.increase(); return next.handle(request).pipe( finalize(() => { diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index dab25a011..f4463de65 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -465,6 +465,11 @@ *mifosxHasPermission="'MENU_TASKS'" > Approvals + @if (approvalTotal > 0) { + {{ + approvalTotal > 99 ? '99+' : approvalTotal + }} + } Cashier diff --git a/src/app/core/shell/shell.component.scss b/src/app/core/shell/shell.component.scss index 95d3060f9..31acfac82 100644 --- a/src/app/core/shell/shell.component.scss +++ b/src/app/core/shell/shell.component.scss @@ -1082,3 +1082,18 @@ $gray-700: #334155; width: $bar; } } + +// How many requests are waiting under Approvals. +.ni-badge { + min-width: 16px; + height: 16px; + margin-left: 4px; + padding: 0 5px; + border-radius: 999px; + background: #f59e0b; + color: #fff; + font-size: 10px; + font-weight: 700; + line-height: 16px; + text-align: center; +} diff --git a/src/app/core/shell/shell.component.ts b/src/app/core/shell/shell.component.ts index 47a81d008..4474746ce 100644 --- a/src/app/core/shell/shell.component.ts +++ b/src/app/core/shell/shell.component.ts @@ -17,6 +17,7 @@ import { TenantMenuAccessService } from '../../directives/has-permission/tenant- /** rxjs Imports */ import { Observable, Subscription } from 'rxjs'; +import { ApprovalCountsService } from '../../tasks/approval-counts.service'; import { map, filter } from 'rxjs/operators'; /** Custom Services */ @@ -68,6 +69,11 @@ export class ShellComponent implements OnInit, OnDestroy { private authService = inject(AuthenticationService); private clerkUiStateService = inject(ClerkUiStateService); private tenantMenuAccessService = inject(TenantMenuAccessService); + private approvalCounts = inject(ApprovalCountsService); + + /** Requests waiting across every Approval Center tab this user can see, shown on the Approvals menu. */ + approvalTotal = 0; + private approvalTotalSub?: Subscription; /** Subscription to breakpoint observer for handset. */ isHandset$: Observable = this.breakpointObserver @@ -144,6 +150,13 @@ export class ShellComponent implements OnInit, OnDestroy { * Subscribes to progress bar to update its mode. */ ngOnInit() { + // The Approvals badge: counts refresh every minute while the app is open, and again whenever the Approval Center opens. + this.approvalTotalSub = this.approvalCounts.total$.subscribe((total) => { + this.approvalTotal = total; + this.cdr.markForCheck(); + }); + this.approvalCounts.start(); + this.progressBar$ = this.progressBarService.updateProgressBar.subscribe((mode: string) => { this.progressBarMode = mode; this.cdr.detectChanges(); @@ -368,6 +381,8 @@ export class ShellComponent implements OnInit, OnDestroy { * Unsubscribes from progress bar. */ ngOnDestroy() { + this.approvalTotalSub?.unsubscribe(); + this.approvalCounts.stop(); if (this.progressBar$) { this.progressBar$.unsubscribe(); } diff --git a/src/app/tasks/approval-counts.service.ts b/src/app/tasks/approval-counts.service.ts new file mode 100644 index 000000000..c28d15bc4 --- /dev/null +++ b/src/app/tasks/approval-counts.service.ts @@ -0,0 +1,193 @@ +/** + * 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 { Injectable, OnDestroy, inject } from '@angular/core'; +import { HttpClient, HttpContext, HttpDownloadProgressEvent, HttpEvent, HttpEventType } from '@angular/common/http'; +import { BehaviorSubject, Subscription } from 'rxjs'; +import { AuthenticationService } from '../core/authentication/authentication.service'; +import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token'; +import { NO_PROGRESS_BAR } from '../core/progress-bar/no-progress.token'; + +/** What the server pushes: the total and, for each Approval Center tab with something waiting, its number. */ +interface ApprovalCounts { + total: number; + counts: Record; +} + +/** After a stream drops, the first wait before reconnecting; it doubles up to the cap. */ +const RETRY_MS = 2_000; +const RETRY_MAX_MS = 60_000; + +/** A stream that ends sooner than this after opening is treated as a failure. */ +const MIN_STREAM_MS = 3_000; + +/** + * How many requests wait on each Approval Center tab, and the total for the Approvals menu. + * + * The numbers are pushed, not polled: the service opens one Server-Sent Events stream, GET /approvals/counts/stream, and + * the server writes a `counts` event the moment the numbers change, so a request raised or actioned by anyone moves the + * badge within about a second with no reloads and no traffic while nothing changes. The server answers numbers only + * (no rows); a tab still loads its own list when it is opened, and applies its own table, status, office scope and + * permission, so a tab the login may not see is absent. + * + * The stream goes through HttpClient (not EventSource, which cannot send the login's headers), so the usual + * authentication and tenant headers apply. It is silent (see SILENT_HTTP_ERROR, NO_PROGRESS_BAR): no error popup and no + * progress bar. The server ends a stream every few minutes and the service reconnects at once; after a failure it keeps + * the last numbers and retries with a growing wait. + * + * A browser tab that is hidden keeps its stream: it is one idle connection, and the badge is right when it is shown. + */ +@Injectable({ providedIn: 'root' }) +export class ApprovalCountsService implements OnDestroy { + private http = inject(HttpClient); + private authenticationService = inject(AuthenticationService); + + private readonly countsSubject = new BehaviorSubject>({}); + private readonly totalSubject = new BehaviorSubject(0); + /** Pending count per tab key; a tab with nothing waiting is absent. */ + readonly counts$ = this.countsSubject.asObservable(); + /** Everything waiting across all the tabs the signed-in user can see. */ + readonly total$ = this.totalSubject.asObservable(); + + private active = false; + private failures = 0; + private stream: Subscription | null = null; + private retryTimer: ReturnType | null = null; + + /** Whether this login reaches the Approval Center at all, so nothing is asked for everyone else. */ + get canSeeApprovals(): boolean { + const permissions = this.authenticationService.getCredentials()?.permissions ?? []; + return permissions.includes('ALL_FUNCTIONS') || permissions.includes('MENU_TASKS'); + } + + /** Opens the stream (once; a second call changes nothing). */ + start(): void { + if (this.active || !this.canSeeApprovals) return; + this.active = true; + // After a page refresh the last numbers show at once; the stream's first event (a moment later) makes them exact. + this.restore(); + this.connect(); + } + + stop(): void { + this.active = false; + this.stream?.unsubscribe(); + this.stream = null; + if (this.retryTimer) clearTimeout(this.retryTimer); + this.retryTimer = null; + this.failures = 0; + } + + ngOnDestroy(): void { + this.stop(); + } + + private connect(): void { + if (!this.active) return; + // Text already handled, so each progress event is only read from where the last one ended. + let consumed = 0; + const startedAt = Date.now(); + this.stream = this.http + .get('/approvals/counts/stream', { + observe: 'events', + reportProgress: true, + responseType: 'text', + headers: { Accept: 'text/event-stream' }, + context: new HttpContext().set(SILENT_HTTP_ERROR, true).set(NO_PROGRESS_BAR, true) + }) + .subscribe({ + next: (event: HttpEvent) => { + if (event.type !== HttpEventType.DownloadProgress) return; + const text = (event as HttpDownloadProgressEvent).partialText ?? ''; + consumed = this.read(text, consumed); + }, + // The server closes a stream after a few minutes: connect again straight away. One that ended almost at once + // was not a stream at all (a proxy answering for it, say), so that is a failure and backs off. + complete: () => (Date.now() - startedAt < MIN_STREAM_MS ? this.retryLater() : this.connect()), + error: () => this.retryLater() + }); + } + + private retryLater(): void { + this.failures += 1; + // While the stream is down (an older server without it, a proxy that blocks it) the badge is still kept right by a + // plain read each time the retry comes round; the stream takes over again as soon as it connects. + this.http + .get('/approvals/counts', { + params: { fresh: 'true' }, + context: new HttpContext().set(SILENT_HTTP_ERROR, true).set(NO_PROGRESS_BAR, true) + }) + .subscribe({ + next: (answer) => { + this.countsSubject.next(answer.counts ?? {}); + this.totalSubject.next(answer.total ?? 0); + this.remember(answer); + }, + error: () => undefined + }); + const wait = Math.min(RETRY_MAX_MS, RETRY_MS * 2 ** (this.failures - 1)); + this.retryTimer = setTimeout(() => this.connect(), wait); + } + + /** Reads every complete event after `from` (events end with a blank line) and returns how far it got. */ + private read(text: string, from: number): number { + let position = from; + let end = text.indexOf('\n\n', position); + while (end !== -1) { + this.handle(text.slice(position, end)); + position = end + 2; + end = text.indexOf('\n\n', position); + } + return position; + } + + private handle(block: string): void { + let name = 'message'; + const data: string[] = []; + for (const line of block.split('\n')) { + if (line.startsWith('event:')) name = line.slice(6).trim(); + else if (line.startsWith('data:')) data.push(line.slice(5).trimStart()); + } + // Comment lines (the server's keep-alive pings) carry no event and are ignored. + if (name !== 'counts' || data.length === 0) return; + try { + const answer = JSON.parse(data.join('\n')) as ApprovalCounts; + this.failures = 0; + this.countsSubject.next(answer.counts ?? {}); + this.totalSubject.next(answer.total ?? 0); + this.remember(answer); + } catch { + // A damaged event is skipped; the next change sends the numbers again. + } + } + + /** Storage is per login, so one user's numbers are never shown to the next person on the same browser. */ + private storageKey(): string { + return `approvalCounts:${this.authenticationService.getCredentials()?.username ?? ''}`; + } + + private remember(answer: ApprovalCounts): void { + try { + localStorage.setItem(this.storageKey(), JSON.stringify({ total: answer.total, counts: answer.counts })); + } catch { + // Storage can be blocked or full; the badge simply starts empty after a refresh. + } + } + + private restore(): void { + try { + const saved = JSON.parse(localStorage.getItem(this.storageKey()) ?? 'null') as ApprovalCounts | null; + if (saved && typeof saved.total === 'number' && saved.counts) { + this.countsSubject.next(saved.counts); + this.totalSubject.next(saved.total); + } + } catch { + // Unreadable or missing: start empty and wait for the first event. + } + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html index d8b22a6c4..19e6096d1 100644 --- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html +++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html @@ -31,6 +31,9 @@ > Checker Inbox + @if (count('checker-inbox')) { + {{ count('checker-inbox') }} + } Client Approval + @if (count('client-approval')) { + {{ count('client-approval') }} + } Loan Approval + @if (count('loan-approval')) { + {{ count('loan-approval') }} + } Loan Disbursal + @if (count('loan-disbursal')) { + {{ count('loan-disbursal') }} + } Reschedule Loan + @if (count('reschedule-loan')) { + {{ count('reschedule-loan') }} + } Pending Account + @if (count('pending-account')) { + {{ count('pending-account') }} + } Pending Share Accounts + @if (count('pending-share-accounts')) { + {{ count('pending-share-accounts') }} + } Product Approval + @if (count('product-approval')) { + {{ count('product-approval') }} + } Pending Account Edits + @if (count('pending-savings-account-edits')) { + {{ count('pending-savings-account-edits') }} + } Pending Client Edits + @if (count('pending-client-edits')) { + {{ count('pending-client-edits') }} + } Pending User Approvals + @if (count('pending-user-edits')) { + {{ count('pending-user-edits') }} + } Withdrawal Requests + @if (count('withdrawal-requests')) { + {{ count('withdrawal-requests') }} + } Cash Transfers + @if (count('cash-transfers')) { + {{ count('cash-transfers') }} + } Voucher Corrections + @if (count('voucher-corrections')) { + {{ count('voucher-corrections') }} + } Share Withdrawal Requests + @if (count('share-withdrawal-requests')) { + {{ count('share-withdrawal-requests') }} + } Dividend Requests + @if (count('dividend-requests')) { + {{ count('dividend-requests') }} + } Share Addition Requests + @if (count('share-addition-requests')) { + {{ count('share-addition-requests') }} + } Deposit Requests + @if (count('deposit-requests')) { + {{ count('deposit-requests') }} + } Disbursement & Transfers + @if (count('disbursement-requests')) { + {{ count('disbursement-requests') }} + } DDS Approvals + @if (count('dds-approvals')) { + {{ count('dds-approvals') }} + } Cheque Book Requests + @if (count('chequebook-requests')) { + {{ count('chequebook-requests') }} + } Closure Requests + @if (count('closure-requests')) { + {{ count('closure-requests') }} + } DD Approvals + @if (count('dd-issue-approvals')) { + {{ count('dd-issue-approvals') }} + } DD Reprint Requests + @if (count('dd-reprint-approvals')) { + {{ count('dd-reprint-approvals') }} + } DD Revoke Requests + @if (count('dd-revoke-approvals')) { + {{ count('dd-revoke-approvals') }} + } Risk Category Changes + @if (count('risk-change-approvals')) { + {{ count('risk-change-approvals') }} + } diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.scss b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.scss index 54431fc8a..cec831d5c 100644 --- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.scss +++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.scss @@ -103,11 +103,30 @@ $amber: #f59e0b; color: inherit; } + // How many requests wait on the tab. + &__count { + min-width: 17px; + height: 17px; + padding: 0 5px; + border-radius: 999px; + background: #f59e0b; + color: #fff; + font-size: 10px; + font-weight: 700; + line-height: 17px; + text-align: center; + } + &:hover:not(.ac-tab--active) { background: #f1f5f9; color: $navy; } + &.ac-tab--active .ac-tab__count { + background: #fff; + color: $blue-d; + } + &.ac-tab--active { background: linear-gradient(135deg, $blue 0%, $blue-d 100%); color: #fff; diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.ts b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.ts index 99e6101f1..a14aa9a94 100644 --- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.ts @@ -7,10 +7,12 @@ */ /** Angular Imports */ -import { Component } from '@angular/core'; +import { Component, OnDestroy, OnInit, inject } from '@angular/core'; import { RouterLinkActive, RouterOutlet } from '@angular/router'; +import { Subscription } from 'rxjs'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { ApprovalCountsService } from '../approval-counts.service'; /** * Approval Center — checker inbox + client/loan approval workflows. @@ -31,6 +33,24 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; FaIconComponent ] }) -export class CheckerInboxAndTasksComponent { - constructor() {} +export class CheckerInboxAndTasksComponent implements OnInit, OnDestroy { + private counts = inject(ApprovalCountsService); + + /** How many requests wait on each tab, by the tab's route segment; a tab with none is absent. */ + pending: Record = {}; + private subscriptions = new Subscription(); + + ngOnInit(): void { + this.subscriptions.add(this.counts.counts$.subscribe((c) => (this.pending = c))); + // The same live counts the Approvals menu badge listens to; they move as requests are raised or actioned. + this.counts.start(); + } + + count(key: string): number { + return this.pending[key] ?? 0; + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } }