Compare commits

..
Author SHA1 Message Date
niteshandClaude Sonnet 5.5 4d205672df 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>
2026-10-15 15:49:36 +05:30
13 changed files with 423 additions and 90 deletions
@@ -184,7 +184,7 @@
class="ws-f__ctl" class="ws-f__ctl"
[(ngModel)]="row.acTyp" [(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
(change)="isCreditOnly(row) && (row.dbCr = 'Cr')" (change)="isGlCreditOnly(row) && (row.dbCr = 'Cr')"
> >
@for (a of acTypOptionsFor(row); track a) { @for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option> <option [value]="a">{{ a }}</option>
@@ -267,7 +267,7 @@
type="button" type="button"
class="ws-seg__btn ws-seg__btn--dr" class="ws-seg__btn ws-seg__btn--dr"
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'" [class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
[disabled]="isCreditOnly(row)" [disabled]="isGlCreditOnly(row)"
(click)="row.dbCr = 'Dr'" (click)="row.dbCr = 'Dr'"
> >
DR DR
@@ -1644,7 +1644,7 @@
class="ws-f__ctl" class="ws-f__ctl"
[(ngModel)]="row.acTyp" [(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
(change)="isCreditOnly(row) && (row.dbCr = 'Cr')" (change)="isGlCreditOnly(row) && (row.dbCr = 'Cr')"
> >
@for (a of acTypOptionsFor(row); track a) { @for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option> <option [value]="a">{{ a }}</option>
@@ -1715,7 +1715,7 @@
type="button" type="button"
class="ws-seg__btn ws-seg__btn--dr" class="ws-seg__btn ws-seg__btn--dr"
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'" [class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
[disabled]="isCreditOnly(row)" [disabled]="isGlCreditOnly(row)"
(click)="$event.stopPropagation(); row.dbCr = 'Dr'" (click)="$event.stopPropagation(); row.dbCr = 'Dr'"
> >
DR DR
@@ -2344,20 +2344,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
); );
} }
/**
* A Fixed Deposit row is credit-only on both the clerk and the cashier screens: an FD is funded once, with
* exactly its principal, and money leaves it only through its maturity or premature closure. The one
* exception is the clerk's own "Close this account" flow, which raises a closure request.
*/
isFdCreditOnly(row: TxnRow): boolean {
return row.acTyp === 'FD' && !this.pendingClosure;
}
/** DR is not offered for this row (see isGlCreditOnly and isFdCreditOnly). */
isCreditOnly(row: TxnRow): boolean {
return this.isGlCreditOnly(row) || this.isFdCreditOnly(row);
}
/** Called when the TYPE dropdown changes — sets sensible defaults for TXN TP and DR/CR */ /** Called when the TYPE dropdown changes — sets sensible defaults for TXN TP and DR/CR */
/** /**
* A cashier row that was filled from an approved token (withdrawal / account-closure) is cash * A cashier row that was filled from an approved token (withdrawal / account-closure) is cash
@@ -5286,20 +5272,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
} }
onSave(): void { onSave(): void {
// Money never leaves a Fixed Deposit from this screen, clerk or cashier: no withdrawal slip, payment,
// collection or transfer debits an FD. It is paid out only through its maturity or premature closure
// (the clerk's "Close this account" flow). The DR button is already disabled for FD rows (isFdCreditOnly);
// this also catches a row set to DR by its TYPE, such as a Withdrawal Slip.
const fdDebitRows = this.rows.filter((r) => this.isFdCreditOnly(r) && r.dbCr === 'Dr');
if (fdDebitRows.length > 0) {
this.showAlert('error', 'Fixed Deposit Cannot Be Debited', [
`FD account(s) ${fdDebitRows.map((r) => r.acNo).join(', ')} cannot be debited or withdrawn from.`,
'Money leaves a Fixed Deposit only through its maturity or premature closure.',
'An FD can only be credited — once, with exactly its principal.'
]);
return;
}
// Demand drafts commit here like everything else on the worksheet. Checked first because a DD // Demand drafts commit here like everything else on the worksheet. Checked first because a DD
// row shares its shape with the ordinary entries below — without this it would fall through and // row shares its shape with the ordinary entries below — without this it would fall through and
// post as a plain transaction, with the draft never raised and the charges silently dropped. // post as a plain transaction, with the draft never raised and the charges silently dropped.
@@ -5377,9 +5349,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return; return;
} }
// Bank transfer: savings (SD) or current (CA/CB) accounts on either side, plus a Fixed Deposit (FD) on // Bank transfer: only savings (SD) or current (CA/CB) accounts allowed
// the CR side only -- funding it from the customer's own account. The server checks the funding rules
// (first and only funding, exactly the principal, the source account's owner and available balance).
const allowedTypes = new Set([ const allowedTypes = new Set([
'SD', 'SD',
'CA', 'CA',
@@ -5387,13 +5357,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
]); ]);
const invalidAccounts = [ const invalidAccounts = [
...drRows, ...drRows,
...crRows.filter((r) => r.acTyp !== 'FD') ...crRows
].filter((r) => !allowedTypes.has(r.acTyp)); ].filter((r) => !allowedTypes.has(r.acTyp));
if (invalidAccounts.length > 0) { if (invalidAccounts.length > 0) {
this.showAlert('error', 'Invalid Account Type', [ this.showAlert('error', 'Invalid Account Type', [
`Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`, `Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`,
'Debit side: only Savings (SD) and Current (CA) accounts. Credit side: SD, CA or a Fixed Deposit (FD).', 'Only Savings (SD) and Current (CA) accounts are permitted.',
'GL and SHR accounts are not allowed — use Disbursement/Collection instead.', 'GL, FD and SHR accounts are not allowed — use Disbursement/Collection instead.',
'RD installments are paid through a cashier Receipt or Auto Debit.' 'RD installments are paid through a cashier Receipt or Auto Debit.'
]); ]);
return; return;
@@ -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 */ /** Custom Services */
import { ProgressBarService } from './progress-bar.service'; import { ProgressBarService } from './progress-bar.service';
import { NO_PROGRESS_BAR } from './no-progress.token';
/** /**
* Http Request interceptor to start/stop loading the progress bar. * 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. * and stop when a response or error is received.
*/ */
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (request.context.get(NO_PROGRESS_BAR)) {
return next.handle(request);
}
this.progressBarService.increase(); this.progressBarService.increase();
return next.handle(request).pipe( return next.handle(request).pipe(
finalize(() => { finalize(() => {
+5
View File
@@ -465,6 +465,11 @@
*mifosxHasPermission="'MENU_TASKS'" *mifosxHasPermission="'MENU_TASKS'"
> >
<fa-icon icon="check" class="ni-ico"></fa-icon>Approvals <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>
<a class="ni" tabindex="0" [matMenuTriggerFor]="cashierMenu" *mifosxHasPermission="'MENU_CASHIER'"> <a class="ni" tabindex="0" [matMenuTriggerFor]="cashierMenu" *mifosxHasPermission="'MENU_CASHIER'">
<fa-icon icon="money-bill" class="ni-ico"></fa-icon>Cashier <fa-icon icon="money-bill" class="ni-ico"></fa-icon>Cashier
+15
View File
@@ -1082,3 +1082,18 @@ $gray-700: #334155;
width: $bar; 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 */ /** rxjs Imports */
import { Observable, Subscription } from 'rxjs'; import { Observable, Subscription } from 'rxjs';
import { ApprovalCountsService } from '../../tasks/approval-counts.service';
import { map, filter } from 'rxjs/operators'; import { map, filter } from 'rxjs/operators';
/** Custom Services */ /** Custom Services */
@@ -68,6 +69,11 @@ export class ShellComponent implements OnInit, OnDestroy {
private authService = inject(AuthenticationService); private authService = inject(AuthenticationService);
private clerkUiStateService = inject(ClerkUiStateService); private clerkUiStateService = inject(ClerkUiStateService);
private tenantMenuAccessService = inject(TenantMenuAccessService); 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. */ /** Subscription to breakpoint observer for handset. */
isHandset$: Observable<boolean> = this.breakpointObserver isHandset$: Observable<boolean> = this.breakpointObserver
@@ -144,6 +150,13 @@ export class ShellComponent implements OnInit, OnDestroy {
* Subscribes to progress bar to update its mode. * Subscribes to progress bar to update its mode.
*/ */
ngOnInit() { 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.progressBar$ = this.progressBarService.updateProgressBar.subscribe((mode: string) => {
this.progressBarMode = mode; this.progressBarMode = mode;
this.cdr.detectChanges(); this.cdr.detectChanges();
@@ -368,6 +381,8 @@ export class ShellComponent implements OnInit, OnDestroy {
* Unsubscribes from progress bar. * Unsubscribes from progress bar.
*/ */
ngOnDestroy() { ngOnDestroy() {
this.approvalTotalSub?.unsubscribe();
this.approvalCounts.stop();
if (this.progressBar$) { if (this.progressBar$) {
this.progressBar$.unsubscribe(); 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> <fa-icon icon="inbox" class="ac-tab__ic"></fa-icon>
<span>Checker Inbox</span> <span>Checker Inbox</span>
@if (count('checker-inbox')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('checker-inbox') }}</span>
}
</a> </a>
<a <a
class="ac-tab" class="ac-tab"
@@ -40,10 +43,16 @@
> >
<fa-icon icon="user-check" class="ac-tab__ic"></fa-icon> <fa-icon icon="user-check" class="ac-tab__ic"></fa-icon>
<span>Client Approval</span> <span>Client Approval</span>
@if (count('client-approval')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('client-approval') }}</span>
}
</a> </a>
<a class="ac-tab" routerLink="./loan-approval" routerLinkActive="ac-tab--active" *mifosxHasPermission="'READ_LOAN'"> <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> <fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
<span>Loan Approval</span> <span>Loan Approval</span>
@if (count('loan-approval')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('loan-approval') }}</span>
}
</a> </a>
<a <a
class="ac-tab" class="ac-tab"
@@ -53,6 +62,9 @@
> >
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon> <fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
<span>Loan Disbursal</span> <span>Loan Disbursal</span>
@if (count('loan-disbursal')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('loan-disbursal') }}</span>
}
</a> </a>
<a <a
class="ac-tab" class="ac-tab"
@@ -62,6 +74,9 @@
> >
<fa-icon icon="calendar-alt" class="ac-tab__ic"></fa-icon> <fa-icon icon="calendar-alt" class="ac-tab__ic"></fa-icon>
<span>Reschedule Loan</span> <span>Reschedule Loan</span>
@if (count('reschedule-loan')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('reschedule-loan') }}</span>
}
</a> </a>
<a <a
class="ac-tab" class="ac-tab"
@@ -71,6 +86,9 @@
> >
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon> <fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
<span>Pending Account</span> <span>Pending Account</span>
@if (count('pending-account')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('pending-account') }}</span>
}
</a> </a>
<a <a
class="ac-tab" class="ac-tab"
@@ -80,6 +98,9 @@
> >
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon> <fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
<span>Pending Share Accounts</span> <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>
<a <a
class="ac-tab" class="ac-tab"
@@ -89,6 +110,9 @@
> >
<fa-icon icon="piggy-bank" class="ac-tab__ic"></fa-icon> <fa-icon icon="piggy-bank" class="ac-tab__ic"></fa-icon>
<span>Product Approval</span> <span>Product Approval</span>
@if (count('product-approval')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('product-approval') }}</span>
}
</a> </a>
<a <a
class="ac-tab" class="ac-tab"
@@ -98,6 +122,9 @@
> >
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon> <fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
<span>Pending Account Edits</span> <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>
<a <a
class="ac-tab" class="ac-tab"
@@ -107,6 +134,9 @@
> >
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon> <fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
<span>Pending Client Edits</span> <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>
<a <a
class="ac-tab" class="ac-tab"
@@ -116,6 +146,9 @@
> >
<fa-icon icon="user-cog" class="ac-tab__ic"></fa-icon> <fa-icon icon="user-cog" class="ac-tab__ic"></fa-icon>
<span>Pending User Approvals</span> <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>
<a <a
class="ac-tab" class="ac-tab"
@@ -143,6 +176,9 @@
> >
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon> <fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
<span>Withdrawal Requests</span> <span>Withdrawal Requests</span>
@if (count('withdrawal-requests')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('withdrawal-requests') }}</span>
}
</a> </a>
<a <a
class="ac-tab" class="ac-tab"
@@ -152,6 +188,9 @@
> >
<fa-icon icon="exchange" class="ac-tab__ic"></fa-icon> <fa-icon icon="exchange" class="ac-tab__ic"></fa-icon>
<span>Cash Transfers</span> <span>Cash Transfers</span>
@if (count('cash-transfers')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('cash-transfers') }}</span>
}
</a> </a>
<a <a
class="ac-tab" class="ac-tab"
@@ -161,6 +200,9 @@
> >
<fa-icon icon="undo" class="ac-tab__ic"></fa-icon> <fa-icon icon="undo" class="ac-tab__ic"></fa-icon>
<span>Voucher Corrections</span> <span>Voucher Corrections</span>
@if (count('voucher-corrections')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('voucher-corrections') }}</span>
}
</a> </a>
<a <a
class="ac-tab" class="ac-tab"
@@ -170,6 +212,9 @@
> >
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon> <fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
<span>Share Withdrawal Requests</span> <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>
<a <a
class="ac-tab" class="ac-tab"
@@ -179,6 +224,9 @@
> >
<fa-icon icon="coins" class="ac-tab__ic"></fa-icon> <fa-icon icon="coins" class="ac-tab__ic"></fa-icon>
<span>Dividend Requests</span> <span>Dividend Requests</span>
@if (count('dividend-requests')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('dividend-requests') }}</span>
}
</a> </a>
<a <a
class="ac-tab" class="ac-tab"
@@ -188,6 +236,9 @@
> >
<fa-icon icon="money-check-alt" class="ac-tab__ic"></fa-icon> <fa-icon icon="money-check-alt" class="ac-tab__ic"></fa-icon>
<span>Share Addition Requests</span> <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>
<a <a
class="ac-tab" class="ac-tab"
@@ -197,6 +248,9 @@
> >
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon> <fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
<span>Deposit Requests</span> <span>Deposit Requests</span>
@if (count('deposit-requests')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('deposit-requests') }}</span>
}
</a> </a>
<a <a
class="ac-tab" class="ac-tab"
@@ -206,10 +260,16 @@
> >
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon> <fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
<span>Disbursement & Transfers</span> <span>Disbursement & Transfers</span>
@if (count('disbursement-requests')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('disbursement-requests') }}</span>
}
</a> </a>
<a class="ac-tab" routerLink="./dds-approvals" routerLinkActive="ac-tab--active"> <a class="ac-tab" routerLink="./dds-approvals" routerLinkActive="ac-tab--active">
<fa-icon icon="coins" class="ac-tab__ic"></fa-icon> <fa-icon icon="coins" class="ac-tab__ic"></fa-icon>
<span>DDS Approvals</span> <span>DDS Approvals</span>
@if (count('dds-approvals')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('dds-approvals') }}</span>
}
</a> </a>
<a <a
class="ac-tab" class="ac-tab"
@@ -219,6 +279,9 @@
> >
<fa-icon icon="money-check" class="ac-tab__ic"></fa-icon> <fa-icon icon="money-check" class="ac-tab__ic"></fa-icon>
<span>Cheque Book Requests</span> <span>Cheque Book Requests</span>
@if (count('chequebook-requests')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('chequebook-requests') }}</span>
}
</a> </a>
<a <a
class="ac-tab" class="ac-tab"
@@ -228,26 +291,41 @@
> >
<fa-icon icon="lock" class="ac-tab__ic"></fa-icon> <fa-icon icon="lock" class="ac-tab__ic"></fa-icon>
<span>Closure Requests</span> <span>Closure Requests</span>
@if (count('closure-requests')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('closure-requests') }}</span>
}
</a> </a>
<a class="ac-tab" routerLink="./dd-issue-approvals" routerLinkActive="ac-tab--active"> <a class="ac-tab" routerLink="./dd-issue-approvals" routerLinkActive="ac-tab--active">
<fa-icon icon="receipt" class="ac-tab__ic"></fa-icon> <fa-icon icon="receipt" class="ac-tab__ic"></fa-icon>
<span>DD Approvals</span> <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>
<a class="ac-tab" routerLink="./dd-reprint-approvals" routerLinkActive="ac-tab--active"> <a class="ac-tab" routerLink="./dd-reprint-approvals" routerLinkActive="ac-tab--active">
<fa-icon icon="print" class="ac-tab__ic"></fa-icon> <fa-icon icon="print" class="ac-tab__ic"></fa-icon>
<span>DD Reprint Requests</span> <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> </a>
<!-- Beside the reprint queue because the two arrive together from the counter, but they are <!-- 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. --> 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"> <a class="ac-tab" routerLink="./dd-revoke-approvals" routerLinkActive="ac-tab--active">
<fa-icon icon="ban" class="ac-tab__ic"></fa-icon> <fa-icon icon="ban" class="ac-tab__ic"></fa-icon>
<span>DD Revoke Requests</span> <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> </a>
<!-- Raised from the Risk Review Report. Approving moves the customer's KYC rating and their <!-- 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. --> 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"> <a class="ac-tab" routerLink="./risk-change-approvals" routerLinkActive="ac-tab--active">
<fa-icon icon="shield-alt" class="ac-tab__ic"></fa-icon> <fa-icon icon="shield-alt" class="ac-tab__ic"></fa-icon>
<span>Risk Category Changes</span> <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> </a>
</nav> </nav>
@@ -103,11 +103,30 @@ $amber: #f59e0b;
color: inherit; 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) { &:hover:not(.ac-tab--active) {
background: #f1f5f9; background: #f1f5f9;
color: $navy; color: $navy;
} }
&.ac-tab--active .ac-tab__count {
background: #fff;
color: $blue-d;
}
&.ac-tab--active { &.ac-tab--active {
background: linear-gradient(135deg, $blue 0%, $blue-d 100%); background: linear-gradient(135deg, $blue 0%, $blue-d 100%);
color: #fff; color: #fff;
@@ -7,10 +7,12 @@
*/ */
/** Angular Imports */ /** Angular Imports */
import { Component } from '@angular/core'; import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { RouterLinkActive, RouterOutlet } from '@angular/router'; import { RouterLinkActive, RouterOutlet } from '@angular/router';
import { Subscription } from 'rxjs';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ApprovalCountsService } from '../approval-counts.service';
/** /**
* Approval Center — checker inbox + client/loan approval workflows. * Approval Center — checker inbox + client/loan approval workflows.
@@ -31,6 +33,24 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
FaIconComponent FaIconComponent
] ]
}) })
export class CheckerInboxAndTasksComponent { export class CheckerInboxAndTasksComponent implements OnInit, OnDestroy {
constructor() {} 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();
}
} }
+41 -43
View File
@@ -45,17 +45,15 @@
"hasMask": false, "hasMask": false,
"ao": 0, "ao": 0,
"ks": { "a": { "a": 0, "k": [ "ks": { "a": { "a": 0, "k": [
0, -17.052704811096184,
0, -15.0467209815979
0
], "ix": 1 }, "s": { "a": 0, "k": [ ], "ix": 1 }, "s": { "a": 0, "k": [
394, 362.228,
394, 362.228,
100 100
], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [
250, 188.2303284168625,
250, 195.49656352277756
0
], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } },
"shapes": [ "shapes": [
{ {
@@ -343,16 +341,16 @@
"g": { "p": 3, "k": { "a": 0, "k": [ "g": { "p": 3, "k": { "a": 0, "k": [
0, 0,
1, 1,
0.6, 0.4470588235294118,
0.2, 0,
0.485, 0.485,
0.9725490196078431,
0.2235294117647059,
0.5019607843137255,
1, 1,
0.6, 0.9411764705882353,
0.2, 0,
1, 1
1,
0.6,
0.2
], "ix": 8 } }, ], "ix": 8 } },
"t": 1, "t": 1,
"a": { "a": 0, "k": 0 }, "a": { "a": 0, "k": 0 },
@@ -365,7 +363,7 @@
"lj": 1, "lj": 1,
"ml": 4, "ml": 4,
"o": { "a": 0, "k": 100, "ix": 9 }, "o": { "a": 0, "k": 100, "ix": 9 },
"w": { "a": 0, "k": 2.5, "ix": 10 } "w": { "a": 0, "k": 2, "ix": 10 }
}, },
{ {
"ty": "fl", "ty": "fl",
@@ -404,8 +402,8 @@
"o": { "o": {
"a": 1, "a": 1,
"k": [ "k": [
{ "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [28], "t": 0 }, { "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [0], "t": 0 },
{ "s": [388], "t": 90.0000036657751 } { "s": [360], "t": 90.0000036657751 }
], ],
"ix": 3 "ix": 3
}, },
@@ -439,7 +437,7 @@
250, 250,
250, 250,
0 0
], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, ], "ix": 2 }, "r": { "a": 0, "k": 60, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } },
"shapes": [ "shapes": [
{ {
"ty": "gr", "ty": "gr",
@@ -725,16 +723,16 @@
], "ix": 5 }, ], "ix": 5 },
"g": { "p": 3, "k": { "a": 0, "k": [ "g": { "p": 3, "k": { "a": 0, "k": [
0, 0,
1, 0,
1, 0.7764705882352941,
1, 1,
0.485, 0.485,
0.24705882352941178,
0.38823529411764707,
1, 1,
1, 1,
1, 0.49411764705882355,
1, 0,
1,
1,
1 1
], "ix": 8 } }, ], "ix": 8 } },
"t": 1, "t": 1,
@@ -748,7 +746,7 @@
"lj": 1, "lj": 1,
"ml": 4, "ml": 4,
"o": { "a": 0, "k": 100, "ix": 9 }, "o": { "a": 0, "k": 100, "ix": 9 },
"w": { "a": 0, "k": 2.5, "ix": 10 } "w": { "a": 0, "k": 0.5, "ix": 10 }
}, },
{ {
"ty": "fl", "ty": "fl",
@@ -815,14 +813,14 @@
0, 0,
0 0
], "ix": 1 }, "s": { "a": 0, "k": [ ], "ix": 1 }, "s": { "a": 0, "k": [
330, 362.228,
330, 362.228,
100 100
], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [
250, 250,
250, 250,
0 0
], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, ], "ix": 2 }, "r": { "a": 0, "k": 30, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } },
"shapes": [ "shapes": [
{ {
"ty": "gr", "ty": "gr",
@@ -1108,17 +1106,17 @@
], "ix": 5 }, ], "ix": 5 },
"g": { "p": 3, "k": { "a": 0, "k": [ "g": { "p": 3, "k": { "a": 0, "k": [
0, 0,
0.0784,
0.7059,
0.2118,
0.485,
0.0784,
0.7059,
0.2118,
1, 1,
0.0784, 0.8235294117647058,
0.7059, 0,
0.2118 0.485,
0.5019607843137255,
0.8117647058823529,
0.5019607843137255,
1,
0,
0.8,
1
], "ix": 8 } }, ], "ix": 8 } },
"t": 1, "t": 1,
"a": { "a": 0, "k": 0 }, "a": { "a": 0, "k": 0 },
@@ -1131,7 +1129,7 @@
"lj": 1, "lj": 1,
"ml": 4, "ml": 4,
"o": { "a": 0, "k": 100, "ix": 9 }, "o": { "a": 0, "k": 100, "ix": 9 },
"w": { "a": 0, "k": 2.5, "ix": 10 } "w": { "a": 0, "k": 0.5, "ix": 10 }
}, },
{ {
"ty": "fl", "ty": "fl",
@@ -1170,8 +1168,8 @@
"o": { "o": {
"a": 1, "a": 1,
"k": [ "k": [
{ "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [-28], "t": 0 }, { "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [0], "t": 0 },
{ "s": [332], "t": 90.0000036657751 } { "s": [360], "t": 90.0000036657751 }
], ],
"ix": 3 "ix": 3
}, },
+6 -6
View File
@@ -325,10 +325,10 @@
}; };
document.head.appendChild(script); document.head.appendChild(script);
// Progress simulation stages (Guaranteed ~4 seconds duration on refresh: 2% every 90ms reaches 90% in about 4s) // Progress simulation stages (Guaranteed ~2 seconds duration on refresh)
var interval = setInterval(function () { var interval = setInterval(function () {
if (progress < 90) { if (progress < 90) {
progress += 2; progress += 5;
} else if (angularReady && progress < 100) { } else if (angularReady && progress < 100) {
progress += 5; progress += 5;
} }
@@ -351,7 +351,7 @@
} }
}, 90); }, 90);
// Enforce minimum hold time (4000ms = 4 seconds) so refresh always shows the animation // Enforce minimum hold time (2000ms = 2 seconds) so refresh always shows the animation
setTimeout(function () { setTimeout(function () {
minTimePassed = true; minTimePassed = true;
if (angularReady) { if (angularReady) {
@@ -359,7 +359,7 @@
if (bar) bar.style.width = '100%'; if (bar) bar.style.width = '100%';
dismissSplash(); dismissSplash();
} }
}, 4000); }, 2000);
function dismissSplash() { function dismissSplash() {
if (dismissed) return; if (dismissed) return;
@@ -387,12 +387,12 @@
} }
}; };
// Safety fallback (Max 9s in case of backend network failure) // Safety fallback (Max 7s in case of backend network failure)
setTimeout(function () { setTimeout(function () {
angularReady = true; angularReady = true;
minTimePassed = true; minTimePassed = true;
dismissSplash(); dismissSplash();
}, 9000); }, 7000);
})(); })();
</script> </script>