feat(approvals): live pending-count badges on every Approval Center tab and the Approvals menu

The badges are pushed, not polled. ApprovalCountsService opens one
Server-Sent Events stream (/approvals/counts/stream) through HttpClient so the
login and tenant headers apply; the server writes a `counts` event the moment
the numbers change.

- Total badge on the Approvals menu, count badge on 26 Approval Center tabs
  (Reject Account and Terminated Account are lists, not requests: no badge).
- Last numbers are kept per login in localStorage, so a page refresh shows them
  at once until the stream's first event makes them exact.
- Stream recycles every few minutes and reconnects at once; after a failure it
  retries with a growing wait and reads /approvals/counts meanwhile.
- NO_PROGRESS_BAR token: the background stream does not run the top progress bar.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 15:49:36 +05:30
co-authored by Claude Sonnet 5.5
parent ab8ed7e67e
commit 4d205672df
9 changed files with 368 additions and 3 deletions
@@ -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<boolean>(() => false);
@@ -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<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (request.context.get(NO_PROGRESS_BAR)) {
return next.handle(request);
}
this.progressBarService.increase();
return next.handle(request).pipe(
finalize(() => {
+5
View File
@@ -465,6 +465,11 @@
*mifosxHasPermission="'MENU_TASKS'"
>
<fa-icon icon="check" class="ni-ico"></fa-icon>Approvals
@if (approvalTotal > 0) {
<span class="ni-badge" title="Requests waiting for approval">{{
approvalTotal > 99 ? '99+' : approvalTotal
}}</span>
}
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="cashierMenu" *mifosxHasPermission="'MENU_CASHIER'">
<fa-icon icon="money-bill" class="ni-ico"></fa-icon>Cashier
+15
View File
@@ -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;
}
+15
View File
@@ -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<boolean> = 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();
}
+193
View File
@@ -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<string, number>;
}
/** 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<Record<string, number>>({});
private readonly totalSubject = new BehaviorSubject<number>(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<typeof setTimeout> | 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<string>) => {
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<ApprovalCounts>('/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.
}
}
}
@@ -31,6 +31,9 @@
>
<fa-icon icon="inbox" class="ac-tab__ic"></fa-icon>
<span>Checker Inbox</span>
@if (count('checker-inbox')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('checker-inbox') }}</span>
}
</a>
<a
class="ac-tab"
@@ -40,10 +43,16 @@
>
<fa-icon icon="user-check" class="ac-tab__ic"></fa-icon>
<span>Client Approval</span>
@if (count('client-approval')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('client-approval') }}</span>
}
</a>
<a class="ac-tab" routerLink="./loan-approval" routerLinkActive="ac-tab--active" *mifosxHasPermission="'READ_LOAN'">
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
<span>Loan Approval</span>
@if (count('loan-approval')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('loan-approval') }}</span>
}
</a>
<a
class="ac-tab"
@@ -53,6 +62,9 @@
>
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
<span>Loan Disbursal</span>
@if (count('loan-disbursal')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('loan-disbursal') }}</span>
}
</a>
<a
class="ac-tab"
@@ -62,6 +74,9 @@
>
<fa-icon icon="calendar-alt" class="ac-tab__ic"></fa-icon>
<span>Reschedule Loan</span>
@if (count('reschedule-loan')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('reschedule-loan') }}</span>
}
</a>
<a
class="ac-tab"
@@ -71,6 +86,9 @@
>
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
<span>Pending Account</span>
@if (count('pending-account')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('pending-account') }}</span>
}
</a>
<a
class="ac-tab"
@@ -80,6 +98,9 @@
>
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
<span>Pending Share Accounts</span>
@if (count('pending-share-accounts')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('pending-share-accounts') }}</span>
}
</a>
<a
class="ac-tab"
@@ -89,6 +110,9 @@
>
<fa-icon icon="piggy-bank" class="ac-tab__ic"></fa-icon>
<span>Product Approval</span>
@if (count('product-approval')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('product-approval') }}</span>
}
</a>
<a
class="ac-tab"
@@ -98,6 +122,9 @@
>
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
<span>Pending Account Edits</span>
@if (count('pending-savings-account-edits')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('pending-savings-account-edits') }}</span>
}
</a>
<a
class="ac-tab"
@@ -107,6 +134,9 @@
>
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
<span>Pending Client Edits</span>
@if (count('pending-client-edits')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('pending-client-edits') }}</span>
}
</a>
<a
class="ac-tab"
@@ -116,6 +146,9 @@
>
<fa-icon icon="user-cog" class="ac-tab__ic"></fa-icon>
<span>Pending User Approvals</span>
@if (count('pending-user-edits')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('pending-user-edits') }}</span>
}
</a>
<a
class="ac-tab"
@@ -143,6 +176,9 @@
>
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
<span>Withdrawal Requests</span>
@if (count('withdrawal-requests')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('withdrawal-requests') }}</span>
}
</a>
<a
class="ac-tab"
@@ -152,6 +188,9 @@
>
<fa-icon icon="exchange" class="ac-tab__ic"></fa-icon>
<span>Cash Transfers</span>
@if (count('cash-transfers')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('cash-transfers') }}</span>
}
</a>
<a
class="ac-tab"
@@ -161,6 +200,9 @@
>
<fa-icon icon="undo" class="ac-tab__ic"></fa-icon>
<span>Voucher Corrections</span>
@if (count('voucher-corrections')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('voucher-corrections') }}</span>
}
</a>
<a
class="ac-tab"
@@ -170,6 +212,9 @@
>
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
<span>Share Withdrawal Requests</span>
@if (count('share-withdrawal-requests')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('share-withdrawal-requests') }}</span>
}
</a>
<a
class="ac-tab"
@@ -179,6 +224,9 @@
>
<fa-icon icon="coins" class="ac-tab__ic"></fa-icon>
<span>Dividend Requests</span>
@if (count('dividend-requests')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('dividend-requests') }}</span>
}
</a>
<a
class="ac-tab"
@@ -188,6 +236,9 @@
>
<fa-icon icon="money-check-alt" class="ac-tab__ic"></fa-icon>
<span>Share Addition Requests</span>
@if (count('share-addition-requests')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('share-addition-requests') }}</span>
}
</a>
<a
class="ac-tab"
@@ -197,6 +248,9 @@
>
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
<span>Deposit Requests</span>
@if (count('deposit-requests')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('deposit-requests') }}</span>
}
</a>
<a
class="ac-tab"
@@ -206,10 +260,16 @@
>
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
<span>Disbursement & Transfers</span>
@if (count('disbursement-requests')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('disbursement-requests') }}</span>
}
</a>
<a class="ac-tab" routerLink="./dds-approvals" routerLinkActive="ac-tab--active">
<fa-icon icon="coins" class="ac-tab__ic"></fa-icon>
<span>DDS Approvals</span>
@if (count('dds-approvals')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('dds-approvals') }}</span>
}
</a>
<a
class="ac-tab"
@@ -219,6 +279,9 @@
>
<fa-icon icon="money-check" class="ac-tab__ic"></fa-icon>
<span>Cheque Book Requests</span>
@if (count('chequebook-requests')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('chequebook-requests') }}</span>
}
</a>
<a
class="ac-tab"
@@ -228,26 +291,41 @@
>
<fa-icon icon="lock" class="ac-tab__ic"></fa-icon>
<span>Closure Requests</span>
@if (count('closure-requests')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('closure-requests') }}</span>
}
</a>
<a class="ac-tab" routerLink="./dd-issue-approvals" routerLinkActive="ac-tab--active">
<fa-icon icon="receipt" class="ac-tab__ic"></fa-icon>
<span>DD Approvals</span>
@if (count('dd-issue-approvals')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('dd-issue-approvals') }}</span>
}
</a>
<a class="ac-tab" routerLink="./dd-reprint-approvals" routerLinkActive="ac-tab--active">
<fa-icon icon="print" class="ac-tab__ic"></fa-icon>
<span>DD Reprint Requests</span>
@if (count('dd-reprint-approvals')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('dd-reprint-approvals') }}</span>
}
</a>
<!-- Beside the reprint queue because the two arrive together from the counter, but they are
different decisions: a reprint re-prints the same leaflet, a revoke spends a fresh one. -->
<a class="ac-tab" routerLink="./dd-revoke-approvals" routerLinkActive="ac-tab--active">
<fa-icon icon="ban" class="ac-tab__ic"></fa-icon>
<span>DD Revoke Requests</span>
@if (count('dd-revoke-approvals')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('dd-revoke-approvals') }}</span>
}
</a>
<!-- Raised from the Risk Review Report. Approving moves the customer's KYC rating and their
re-KYC due date together, so it belongs with the other maker-checker queues. -->
<a class="ac-tab" routerLink="./risk-change-approvals" routerLinkActive="ac-tab--active">
<fa-icon icon="shield-alt" class="ac-tab__ic"></fa-icon>
<span>Risk Category Changes</span>
@if (count('risk-change-approvals')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('risk-change-approvals') }}</span>
}
</a>
</nav>
@@ -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;
@@ -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<string, number> = {};
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();
}
}