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:
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user