Compare commits

..
Author SHA1 Message Date
niteshandClaude Opus 5.5 a87cf42c5c feat(ui): compact navigation with grouped mega menus and branded loaders
Navigation bar (shell.component.html/.scss)
- Restyle top-level nav items as compact tabs and add responsive
  breakpoints (1400px, 1200px, 1080px, 880px) that shrink icon, caret
  and padding so the full menu fits on smaller laptop screens.
- Long labels now carry a short form shown on narrow widths:
  Loan Origination -> Loans, General Ledger -> GL, Sub Ledger -> Sub GL,
  Reconciliation -> Recon, Documents Hub -> Docs Hub.
- Reports and Insurance menus are regrouped into titled mega panels
  (reports-mega-panel, insurance-mega-panel) using deposit-menu-group
  sections instead of one long flat list.
- No route was added or removed: all 64 menu links (Account Register,
  Day Book, End of the Day, Form 97/98, Risk Review, Re-KYC, Dormancy,
  Savings/FD Interest Posting, TDS, BOR, DIP lookups, digital payment
  reports, CKYC, DICGC, Clerk Desk, ...) still point to the same routes.

Global mega panel styles (main.scss)
- Shared grid and overlay styling for every Material mega menu
  (cdk-overlay-pane with a menu panel) plus a coloured top accent per
  panel: admin, cashier, clerk, branch-ops, hrm, digital, reports,
  share, others, npa, letter, insurance, ckyc and reconciliation.
- Fixed column layouts for the dense panels (NPA 3 columns,
  Reconciliation 5 columns, Reports, CKYC).

Toolbar (toolbar.component.html/.scss)
- User account dropdown gets its own t-user-menu panel style and opens
  towards the left (xPosition="before") so it no longer clips at the
  screen edge.
- Restyled AI assistant menu cards.

Density and products page
- Material density lowered from 0 to -1 (mifosx-theme.scss) for a
  tighter, more data-dense UI.
- Products page cards: wider container (1280px), smaller icons, fonts,
  gaps and padding to match the compact look.

Loaders (index.html, loading-screen component)
- Boot splash in index.html and the fullscreen mifosx-loading-screen now
  show the CBS Nexora logo image (assets/images/cbsnexora-logo.png)
  instead of the generic four-square SVG and text title.

Lint clean-up required by the pre-commit hook
- Prettier formatting and stylelint auto-fixes (modern rgb() notation,
  range media queries, shorthand properties, empty lines before rules).
- Merged duplicate selectors into their first block without changing
  the cascade: the second .reports-mega-panel (top accent border) in
  main.scss and the second ::ng-deep .ai-menu (AI card styles) in
  toolbar.component.scss.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:55:03 +05:30
niteshandClaude Opus 5.5 3928936b93 feat(ui): animated boot splash and loading screen
Show a Lottie-animated splash while the app bundle boots and dismiss it
once WebAppComponent initialises, plus a reusable mifosx-loading-screen
component with loading and error animations. lottie.min.js is served
from the lottie-web package instead of being vendored into src/assets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:34:31 +05:30
niteshandClaude Sonnet 5 799a6930b8 revert(fixed-deposits): remove FD maturity approval UI
Removes the FD Maturity Reports screen, its route and nav entry, and
the maturing-today section on Branch Day Closing — the backend report
and approval gate behind them are gone, and a matured deposit's final
interest already surfaces under FD Interest Posting Reports'
"Posted outside approval".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-09 12:50:24 +05:30
niteshandClaude Sonnet 5 1cebb84906 feat(fixed-deposits): FD Maturity Reports component (missed from prior commit)
The component, service and template for the FD Maturity Reports screen
itself — the previous commit wired up its route, nav entry and the
day-close/product-summary integration, but the new directory was never
staged alongside them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 22:38:56 +05:30
niteshandClaude Sonnet 5 c90528e109 feat(fixed-deposits): maturity approval UI, scoped to deposits with no closure instruction
Adds the FD Maturity Reports screen (due-without-report, generate/
approve, branch and head-office scope, detail with closure instruction
and estimated interest per deposit), a nav entry, the route, and
Branch Day Closing's maturing-today section and approval-pending
warning banner.

Adds "Matured automatically" to the maturity screen: deposits that
settled via their own Transfer-to-Savings or Reinvest instruction,
needing no approval, listed for visibility with product totals and
per-deposit detail.

Adds "Product summary" to the FD Interest Posting Reports screen
(head office): interest by product and scheme — posted through the
report, still pending, and posted directly at maturity instead — added
up to what each product has paid out in total over a period.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 19:06:57 +05:30
niteshandClaude Opus 5 79bffaca69 chore(tenants): register the new local "mybank" tenant in the UI tenant list
Added 'mybank' to fineractPlatformTenantIds in all three places the tenant
selector reads it from, so the local database + tenant-store registration just
created (fineract_mybank, identifier "mybank", name "MyBank", copied from
lincpay's local connection settings onto a fresh empty schema) shows up as a
login option:

- src/assets/env.js (window['env'] default, read before Angular bootstraps)
- src/environments/environment.ts (dev build fallback)
- src/environments/environment.prod.ts (prod build fallback)

Each already listed utkarshbank, lincpay, utkarshnagari, testbank, humarwork,
default -- this only appends mybank, nothing else in the list changes.

.env.ts is the auto-generated build stamp (npm run env) and picked up
incidentally alongside; it carries no logic.

Not included in this commit: the fd-interest-posting / fd-maturity-report /
branch-day / shell.component.html changes sitting in the working tree are a
separate, unrelated, in-progress feature and are deliberately left out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-08 18:51:22 +05:30
niteshandClaude Sonnet 5 4acee3e63b feat(fixed-deposits): show the posting period and GL-reconciled maturity interest
Each report line now shows the span its interest covers — "01 Oct to
31 Dec" rather than just the end date — and the report header shows
the product's posting cycle (Monthly, Quarterly, Half-yearly, Yearly).

Adds a "Posted outside approval" view: deposit interest posted without
this report's approval, chiefly a deposit that matured before its own
posting cycle completed. Product-wise totals plus a detail list with
when each posting actually reached the account.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 00:07:41 +05:30
niteshandClaude Sonnet 5 cec5223378 feat(fixed-deposits): FD interest posting approval UI
Adds the FD Interest Posting Reports screen (due-without-report,
generate/approve, branch and head-office scope, posting detail,
live preview, head-office summary), a nav entry under Reports, the
route, and Branch Day Closing's fixed-deposit-due section and
approval-pending warning banner that blocks closing the day.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 00:07:18 +05:30
85 changed files with 12054 additions and 4539 deletions
+7
View File
@@ -31,6 +31,8 @@
"@angular/localize/init"
],
"assets": [
"src/favicon.ico",
"src/apple-touch-icon.png",
"src/assets/images/cbsnexora-favicon.svg",
"src/robots.txt",
"src/manifest.json",
@@ -39,6 +41,11 @@
"glob": "**/*",
"input": "node_modules/tinymce",
"output": "assets/tinymce"
},
{
"glob": "lottie.min.js",
"input": "node_modules/lottie-web/build/player",
"output": "assets/js"
}
],
"styles": [
+7
View File
@@ -41,6 +41,7 @@
"jspdf-autotable": "^5.0.2",
"lightgallery": "^2.9.0",
"lodash": "4.17.23",
"lottie-web": "^5.13.0",
"moment": "^2.29.4",
"ngx-image-cropper": "^9.1.6",
"ngx-mat-select-search": "^8.0.2",
@@ -18380,6 +18381,12 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/lottie-web": {
"version": "5.13.0",
"resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz",
"integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+1
View File
@@ -71,6 +71,7 @@
"jspdf-autotable": "^5.0.2",
"lightgallery": "^2.9.0",
"lodash": "4.17.23",
"lottie-web": "^5.13.0",
"moment": "^2.29.4",
"ngx-image-cropper": "^9.1.6",
"ngx-mat-select-search": "^8.0.2",
+2
View File
@@ -96,6 +96,7 @@ import { TokenInterceptor as ZitadelTokenInterceptor } from './zitadel/token.int
import { AuthService } from './zitadel/auth.service';
import { environment } from '../environments/environment';
import { CallbackComponent } from './zitadel/callback/callback.component';
import { LoadingScreenComponent } from './shared/loading-screen/loading-screen.component';
import { OAuthModule } from 'angular-oauth2-oidc';
export class CustomMissingTranslationHandler implements MissingTranslationHandler {
@@ -195,6 +196,7 @@ export function HttpLoaderFactory(http: HttpClient) {
AppRoutingModule,
NotFoundComponent,
CallbackComponent,
LoadingScreenComponent,
OAuthModule.forRoot()
],
providers: [
@@ -12,7 +12,6 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface RegisterRow {
accountId: number;
@@ -84,7 +83,7 @@ export class AccountRegisterComponent implements OnInit {
/** Local date — toISOString() would shift the day in any timezone behind UTC. */
private today(): string {
const d = businessToday();
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -352,6 +352,51 @@
</div>
}
<!-- Interest waiting to be posted as this branch closes. Posting runs as part of the close, so
anything unapproved stops it: the branch sees here what it has to approve and where, rather
than finding out from a refusal after filling in the whole count. -->
@if (preview?.fdInterestDue?.length) {
<div class="bd-calc bd-calc--fd">
<div class="bd-calc__head">
<mat-icon>savings</mat-icon>
<strong>Fixed deposit interest due today</strong>
</div>
@for (d of preview.fdInterestDue; track d.productId) {
<div class="bd-calc__row">
<span>
{{ d.productName }} · {{ d.fdType }} · {{ d.accountCount }} deposit(s)
@if (d.reportStatus === 'APPROVED' || d.reportStatus === 'POSTED') {
<span class="bd-fd-ok">{{ d.reportStatus }}</span>
} @else {
<span class="bd-fd-pending">{{ d.reportStatus === 'NONE' ? 'NO REPORT' : d.reportStatus }}</span>
}
</span>
<span class="bd-calc__amt">₹{{ d.totalInterest | number: '1.2-2' }}</span>
</div>
}
</div>
}
@if (preview?.fdInterestPending?.length) {
<div class="bd-warn">
<mat-icon>warning</mat-icon>
<div>
<strong>The day cannot be closed yet.</strong> Fixed deposit interest is not approved for:
{{ preview.fdInterestPending.join(', ') }}. Approve it under Reports → FD Interest Posting Reports, then
close the day. The user who generated a report cannot approve it.
</div>
</div>
}
@if (preview?.savingsInterestPending?.length) {
<div class="bd-warn">
<mat-icon>warning</mat-icon>
<div>
<strong>The day cannot be closed yet.</strong> Savings interest is not approved for:
{{ preview.savingsInterestPending.join(', ') }}. Approve it under Reports → Interest Posting Reports, then
close the day.
</div>
</div>
}
<!-- What every cash point under this branch is actually holding right now, fetched
automatically — so closing the day doesn't mean separately checking Vault Position and
every cashier's own Cashier Position screen by hand. -->
@@ -195,6 +195,75 @@
font-size: 11px;
color: #94a3b8;
}
/* Names the block, for the interest panels that sit beside the cash arithmetic. */
&__head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
padding-bottom: 6px;
border-bottom: 1px solid #e2e8f0;
font-size: 13px;
color: #0d1b2a;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #1d4ed8;
}
}
}
/* Interest is money leaving as the day closes, not cash counted in the drawer. */
.bd-calc--fd {
border-left: 3px solid #1d4ed8;
}
.bd-fd-ok,
.bd-fd-pending {
display: inline-block;
margin-left: 6px;
padding: 1px 7px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.02em;
}
.bd-fd-ok {
background: #dcfce7;
color: #15803d;
}
.bd-fd-pending {
background: #fef3c7;
color: #b45309;
}
/* Why the close will be refused, said before the count is filled in rather than after. */
.bd-warn {
display: flex;
align-items: flex-start;
gap: 8px;
max-width: 640px;
margin-bottom: 14px;
padding: 10px 14px;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 8px;
font-size: 12px;
line-height: 1.5;
color: #78350f;
mat-icon {
flex-shrink: 0;
font-size: 20px;
width: 20px;
height: 20px;
color: #b45309;
}
}
.bd-hint {
@@ -11,7 +11,6 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ExportSheetSpec, exportWorkbook } from '../cashier-export';
import { businessToday } from 'app/core/utils/business-today';
interface SummaryRow {
code: string;
@@ -1317,7 +1316,7 @@ export class CashClearingTransferSummaryComponent {
private dateTag = '2607';
constructor() {
const d = businessToday();
const d = new Date();
this.asOnDate = `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
this.dateTag = `${String(d.getFullYear()).slice(-2)}${String(d.getMonth() + 1).padStart(2, '0')}`;
@@ -12,7 +12,6 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -64,7 +63,7 @@ export class CashExchangeEntryComponent implements OnInit {
}
ngOnInit(): void {
const d = businessToday();
const d = new Date();
this.exchangeDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
@@ -18,7 +18,6 @@ import { MatInputModule } from '@angular/material/input';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
type PageState = 'loading' | 'no-cashier' | 'needs-reconciliation' | 'already-settled' | 'form';
@@ -91,7 +90,7 @@ export class CashSettlementComponent implements OnInit {
saving = false;
ngOnInit(): void {
const d = businessToday();
const d = new Date();
this.settlementDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
.getTellers()
@@ -17,7 +17,6 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -83,7 +82,7 @@ export class CashTransferComponent implements OnInit {
loadingVault = false;
ngOnInit(): void {
const d = businessToday();
const d = new Date();
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
@@ -17,7 +17,6 @@ import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -72,7 +71,7 @@ export class CashierPositionComponent implements OnInit {
isCashierMode = false;
ngOnInit(): void {
const d = businessToday();
const d = new Date();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.isCashierMode = this.authService.isCashierUser();
@@ -51,12 +51,6 @@ export class ClerkShareWithdrawalService {
return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {});
}
/** Runs the exact same checklist approve() will run, against a draft that hasn't been submitted
* yet — so the clerk sees it in the confirm dialog, before committing to the request. */
previewValidate(data: any): Observable<any> {
return this.http.post('/clerk/share-withdrawal-requests/preview-validate', data);
}
approve(id: number): Observable<any> {
return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {});
}
@@ -12,7 +12,6 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
/** Day-wise denomination-wise closing cash collected from deposits. */
@Component({
@@ -205,7 +204,7 @@ export class ClosingCashReportComponent implements OnInit {
}
ngOnInit(): void {
const d = businessToday();
const d = new Date();
this.date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.load();
this.service.getTellers().subscribe({ next: (t: any[]) => (this.tellers = t || []), error: () => {} });
@@ -13,7 +13,6 @@ import { MatIconModule } from '@angular/material/icon';
import { Router } from '@angular/router';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
/**
* Which part of the day's work a row came from. Set by the server, one per source register.
@@ -96,7 +95,7 @@ export class DayBookComponent implements OnInit {
/** Local date as yyyy-MM-dd — toISOString() would shift the day in any timezone behind UTC. */
private today(): string {
const d = businessToday();
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -17,7 +17,6 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -80,7 +79,7 @@ export class InterCashierTransferComponent implements OnInit {
}
ngOnInit(): void {
const d = businessToday();
const d = new Date();
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
+1 -2
View File
@@ -14,7 +14,6 @@ import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { catchError, finalize, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
import { businessToday } from 'app/core/utils/business-today';
/** What the signed-in user's part in a voucher was. */
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
@@ -358,7 +357,7 @@ export class MyWorkComponent implements OnInit {
}
private todayIso(): string {
const d = businessToday();
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
}
@@ -18,7 +18,6 @@ import { MatTabsModule } from '@angular/material/tabs';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -86,7 +85,7 @@ export class ReconciliationComponent implements OnInit {
loadingHistory = false;
ngOnInit(): void {
const d = businessToday();
const d = new Date();
this.reconDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
.getTellers()
@@ -11,7 +11,7 @@ import { CommonModule } from '@angular/common';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http';
import { catchError, debounceTime, distinctUntilChanged, map, timeout } from 'rxjs/operators';
import { catchError, debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
import { forkJoin, of, Subject } from 'rxjs';
import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
@@ -33,7 +33,6 @@ import {
import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service';
import { OrganizationService } from '../../organization/organization.service';
import { CashierIssueDdDialogComponent } from './cashier-issue-dd-dialog.component';
import { businessToday, businessTodayIso } from 'app/core/utils/business-today';
interface TxnRow {
typ: string;
@@ -622,7 +621,7 @@ export class AccountStatementDialogComponent implements OnInit {
@ViewChild('statementContent') statementContent!: ElementRef;
today = businessTodayIso();
today = new Date().toISOString().slice(0, 10);
fromDate = '';
toDate = this.today;
@@ -1871,7 +1870,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
saving = false;
ngOnInit(): void {
const d = businessToday();
const d = new Date();
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
this.addRow();
this.loadDenominations();
@@ -2104,7 +2103,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
private loadOpeningPosition(): void {
const d = businessToday();
const d = new Date();
const asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.cashierService
.getCashierPositionForMe(asOfDate)
@@ -2838,7 +2837,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
fundingAccountId: number,
fundingClientId: number | null
): void {
const d = businessToday();
const d = new Date();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
const isGl = row.acTyp === 'GL';
@@ -2970,7 +2969,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const d = businessToday();
const d = new Date();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
@@ -3069,7 +3068,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const d = businessToday();
const d = new Date();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const payload = {
@@ -3392,7 +3391,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = businessToday();
const today = new Date();
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
@@ -3426,7 +3425,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
meetingDate: shareRow.meetingDate,
description: shareRow.description || null,
settlementPaymentTypeId: savingsRow.paymentTypeId,
settlementMode: 'SAVINGS',
settlementRowSnapshot
};
@@ -3457,60 +3455,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
: 'Pending Manager approval — redemption and settlement both post together on approval.'
];
this.showShareWithdrawalConfirm(payload, confirmLines, () =>
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
this.doSubmitShareRedemptionTransfer(payload, shareRow, shares, linkedAcNo)
);
}
/**
* Shows the confirm dialog with the same checklist the Manager will see when approving this
* request — fetched live from the backend (`previewValidate`) rather than re-implemented here,
* so it can never show the clerk something different from what the approver actually sees. A
* failing check is shown, not blocked: approve() enforces its own hard rules separately (e.g.
* Share Advance Collection applied), this list is advisory so problems surface to whoever can
* still fix them, before a manager's time is spent on a request that was always going to fail.
*/
private showShareWithdrawalConfirm(
payload: Record<string, unknown>,
baseLines: string[],
onConfirm: () => void
): void {
this.clerkShareWithdrawalService
.previewValidate(payload)
.pipe(
// A request that never resolves (slow network, server busy) must not leave the Submit
// button looking dead — the dialog still has to open, just without the checklist.
timeout(5000),
catchError(() => of({ __previewFailed: true }))
)
.subscribe((res: any) => {
const checks: { label: string; pass: boolean; detail: string }[] = res?.checks ?? [];
// A failed call (most likely the backend not yet carrying the /preview-validate endpoint)
// says so explicitly rather than just omitting the section — a silently empty checklist
// looks identical to "everything passed, nothing to show", which is the one reading this
// must never give.
const checkLines = res?.__previewFailed ? [
'',
'⚠ Validation checklist unavailable right now — the request can still be submitted.'
] : checks.length ? [
'',
'Validation Checks (same as Manager approval):',
...checks.map((c) => `${c.pass ? '✓' : '⚠'} ${c.label}: ${c.detail}`)
] : [];
// baseLines ends with a blank line followed by the "Pending Manager approval…" footer
// sentence, which must stay last so the modal renders it as a footnote rather than a
// key/value row — so the checklist is spliced in just before that pair, not appended after.
const footerStart =
baseLines.length >= 2 && baseLines[baseLines.length - 2] === '' ? baseLines.length - 2 : baseLines.length;
const lines = [
...baseLines.slice(0, footerStart),
...checkLines,
...baseLines.slice(footerStart)
];
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', lines, 'Submit for Approval', onConfirm);
});
}
/**
* Share redemption settled to a GL account the clerk picks on the CR row, instead of the
* member's linked savings account — mirrors submitShareRedemptionTransfer() above, but the
@@ -3563,7 +3512,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = businessToday();
const today = new Date();
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
@@ -3622,7 +3571,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
: 'Pending Manager approval — redemption and settlement both post together on approval.'
];
this.showShareWithdrawalConfirm(payload, confirmLines, () =>
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
this.doSubmitShareGlSettlementTransfer(payload, shareRow, shares, glRow.acNo)
);
}
@@ -4224,7 +4173,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
): void {
const accountUrl = `${cfg.detail}/${accountId}${cfg.detailParams ?? '?associations=all'}`;
const todayStr = (() => {
const d = businessToday();
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
})();
@@ -4600,7 +4549,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
get todayIso(): string {
const d = businessToday();
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -5122,7 +5071,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.sameDayChequeClearing = 0;
return;
}
const today = businessToday();
const today = new Date();
const sameDay = (d: any) => {
if (!d) return false;
const t = Array.isArray(d) && d.length >= 3 ? new Date(d[0], d[1] - 1, d[2]) : new Date(d);
@@ -6010,7 +5959,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = businessToday();
const today = new Date();
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
const payload: any = {
@@ -6205,7 +6154,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
/** Build the withdrawal-request payload from the current row + loaded account. */
private buildWithdrawalPayload(row: TxnRow, amount: number): any {
const today = businessToday();
const today = new Date();
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
@@ -6310,7 +6259,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
/** Build the deposit-request payload from the current row + loaded account. */
private buildDepositPayload(row: TxnRow, amount: number): any {
const today = businessToday();
const today = new Date();
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
// Send denominations only for cash payments — stale counts on a cheque
@@ -6421,7 +6370,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = businessToday();
const today = new Date();
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
@@ -16,7 +16,6 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -68,7 +67,7 @@ export class VaultPositionComponent implements OnInit {
errorMsg = '';
ngOnInit(): void {
const d = businessToday();
const d = new Date();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
@@ -13,7 +13,6 @@ import { ActivatedRoute } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
/**
* Which side of the register a screen shows.
@@ -228,7 +227,7 @@ export class VoucherReportComponent implements OnInit {
}
private todayIso(): string {
const now = businessToday();
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
@@ -129,31 +129,6 @@
<span>* Date of Meeting</span>
<input type="date" [(ngModel)]="meetingDate" [disabled]="isReview" />
</label>
<label class="inv-form-field">
<span>* Settlement Destination</span>
@if (!isReview) {
<select [ngModel]="settlementMode" (ngModelChange)="onSettlementModeChange($event)">
<option value="SAVINGS">Member's Linked Savings Account</option>
<option value="GL">GL Account</option>
</select>
} @else {
<input
type="text"
[value]="reviewRequest?.settlement_mode === 'GL' ? 'GL Account' : 'Linked Savings Account'"
readonly
/>
}
</label>
@if (!isReview && settlementMode === 'GL') {
<label class="inv-form-field">
<span>* Settlement GL Account</span>
<mifosx-gl-account-selector
[inputFormControl]="toGlAccountControl"
[glAccountList]="glAccountsData"
[required]="true"
></mifosx-gl-account-selector>
</label>
}
<label class="inv-form-field">
<span>* Settlement Payment Type</span>
@if (!isReview) {
@@ -411,25 +386,14 @@
<td>Date of Meeting</td>
<td>{{ meetingDate }}</td>
</tr>
<tr>
<td>Settlement Destination</td>
<td>{{ settlementMode === 'GL' ? 'GL Account' : 'Linked Savings Account' }}</td>
</tr>
@if (settlementMode === 'GL') {
<tr>
<td>Settlement GL Account</td>
<td>{{ selectedSettlementGlName }}</td>
</tr>
} @else {
<tr>
<td>Linked Savings Account</td>
<td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td>
</tr>
}
<tr>
<td>Settlement Payment Type</td>
<td>{{ settlementPaymentTypeName }}</td>
</tr>
<tr>
<td>Linked Savings Account</td>
<td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td>
</tr>
</tbody>
</table>
@@ -8,7 +8,7 @@
import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, UntypedFormControl } from '@angular/forms';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
@@ -19,9 +19,6 @@ import { SharesService } from '../../shares/shares.service';
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
import { CashierService } from '../../cashier/cashier.service';
import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component';
import { GlAccountSelectorComponent } from '../../shared/accounting/gl-account-selector/gl-account-selector.component';
import { AccountingService } from '../../accounting/accounting.service';
import { GLAccount } from '../../shared/models/general.model';
export type ShareWdMode = 'create' | 'review';
@@ -54,8 +51,7 @@ interface ActivityEntry {
RouterModule,
MatIconModule,
MatSnackBarModule,
SearchSelectComponent,
GlAccountSelectorComponent
SearchSelectComponent
]
})
export class ShareWithdrawalRequestComponent implements OnInit {
@@ -64,7 +60,6 @@ export class ShareWithdrawalRequestComponent implements OnInit {
private sharesService = inject(SharesService);
private wdService = inject(ClerkShareWithdrawalService);
private cashierService = inject(CashierService);
private accountingService = inject(AccountingService);
private snackBar = inject(MatSnackBar);
/** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode
@@ -96,17 +91,6 @@ export class ShareWithdrawalRequestComponent implements OnInit {
paymentTypes: any[] = [];
settlementPaymentTypeId: number | null = null;
/** Where the redeemed value goes. 'SAVINGS' (the long-standing default) credits the member's
* linked savings account. 'GL' lets the clerk pick any postable GL account directly — same
* option the Cashier's Transaction Entry screen already offers on its CR row, now available
* here too so both entry points behave the same way. Naming the product's own Share Sundry GL
* here is still fine: the backend recognises it and books straight to Sundry with no further
* settlement leg (see ClerkShareWithdrawalRequestApiResource#submit). */
settlementMode: 'SAVINGS' | 'GL' = 'SAVINGS';
settlementGlAccountId: number | null = null;
glAccountsData: GLAccount[] = [];
toGlAccountControl = new UntypedFormControl('');
activityLog: ActivityEntry[] = [];
reviewRequest: any = null;
showRejectDialog = false;
@@ -148,17 +132,6 @@ export class ShareWithdrawalRequestComponent implements OnInit {
return this.selectedSettlementPaymentType?.name || '—';
}
/** The chosen GL account's display name, for the confirm dialog and the checklist. Falls back to
* the raw id while the GL account list is still loading. */
get selectedSettlementGlName(): string {
if (!this.settlementGlAccountId) {
return '—';
}
return (
this.glAccountsData.find((g) => g.id === this.settlementGlAccountId)?.name || `GL #${this.settlementGlAccountId}`
);
}
get isFormValid(): boolean {
if (!this.account) return false;
if (!this.requestedShares || this.requestedShares <= 0) return false;
@@ -168,7 +141,6 @@ export class ShareWithdrawalRequestComponent implements OnInit {
if (!this.resolutionNumber.trim()) return false;
if (!this.meetingDate) return false;
if (!this.settlementPaymentTypeId) return false;
if (this.settlementMode === 'GL' && !this.settlementGlAccountId) return false;
return true;
}
@@ -188,18 +160,11 @@ export class ShareWithdrawalRequestComponent implements OnInit {
pass: shares > 0 && shares <= held,
detail: `${shares} of ${held} shares held`
},
this.settlementMode === 'GL'
? {
label: 'Settlement GL Account selected',
pass: !!this.settlementGlAccountId,
detail: this.settlementGlAccountId ? this.selectedSettlementGlName : 'Missing'
}
: {
label: 'Linked savings account confirmed',
pass: !!this.account?.savingsAccountNumber,
detail:
this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
},
{
label: 'Linked savings account confirmed',
pass: !!this.account?.savingsAccountNumber,
detail: this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
},
{
label: 'Settlement Payment Type selected (non-cash)',
pass: this.settlementPaymentTypeName !== '—',
@@ -249,37 +214,11 @@ export class ShareWithdrawalRequestComponent implements OnInit {
.pipe(catchError(() => of([])))
.subscribe((types: any[]) => (this.paymentTypes = types ?? []));
this.toGlAccountControl.valueChanges.subscribe((id: any) => {
this.settlementGlAccountId = id ? Number(id) : null;
});
if (this.requestId && this.mode === 'review') {
this.loadReviewRequest(this.requestId);
}
}
/** Flips the settlement destination. Switching away from GL clears whatever was picked so a
* stale GL id can never ride along on a SAVINGS submission. */
onSettlementModeChange(mode: 'SAVINGS' | 'GL'): void {
this.settlementMode = mode;
if (mode === 'GL') {
this.loadGlAccounts();
} else {
this.settlementGlAccountId = null;
this.toGlAccountControl.setValue('');
}
}
/** Fetched on first use rather than with the page, since most withdrawals settle to savings. */
private loadGlAccounts(): void {
if (this.glAccountsData.length) {
return;
}
this.accountingService.getGlAccounts().subscribe((accounts: GLAccount[]) => {
this.glAccountsData = accounts ?? [];
});
}
onAccountSelected(accountId: string): void {
this.selectedAccountId = accountId;
if (!accountId) {
@@ -307,11 +246,6 @@ export class ShareWithdrawalRequestComponent implements OnInit {
this.resolutionNumber = req.resolution_number ?? '';
this.meetingDate = req.meeting_date ? String(req.meeting_date).split('T')[0].split(' ')[0] : this.meetingDate;
this.description = req.description ?? '';
this.settlementMode = req.settlement_mode === 'GL' ? 'GL' : 'SAVINGS';
this.settlementGlAccountId = req.settlement_gl_account_id ? Number(req.settlement_gl_account_id) : null;
if (this.settlementMode === 'GL') {
this.loadGlAccounts();
}
if (req.activity_log) {
try {
@@ -355,9 +289,7 @@ export class ShareWithdrawalRequestComponent implements OnInit {
resolutionNumber: this.resolutionNumber.trim(),
meetingDate: this.meetingDate,
description: this.description,
settlementPaymentTypeId: this.settlementPaymentTypeId,
settlementMode: this.settlementMode,
...(this.settlementMode === 'GL' ? { settlementGlAccountId: this.settlementGlAccountId } : {})
settlementPaymentTypeId: this.settlementPaymentTypeId
};
this.wdService.submit(payload).subscribe({
next: () => {
@@ -116,8 +116,8 @@
<div class="f121__note f121__note--warn">
<mat-icon>warning_amber</mat-icon>
<div>
A declaration is on record for this year, but one or more deposits still carry a tax group. Those accounts
deduct on their own and ignore the declaration — remove the tax group from them.
A Form 121 declaration is on record for this year, but withholding is still switched on for one or more
deposits. Turn it off on those accounts, or tax will keep being deducted.
</div>
</div>
}
@@ -192,90 +192,20 @@
}
</span>
</div>
@if (declarationRecord; as d) {
@if (declarationOnRecord && declarationFy) {
<div class="f121__kv-row">
<span>Declared for</span>
<span>FY {{ d.fy }} · Form {{ d.formType }}</span>
<span>FY {{ declarationFy }}</span>
</div>
<div class="f121__kv-row">
<span>Accepted on</span>
<span>
{{ d.acceptedOn | date: 'd MMM y' }} — no TDS on interest credited from this day to 31 March. Interest
credited before it is taxed as usual.
</span>
</div>
@if (d.uin) {
<div class="f121__kv-row">
<span>UIN</span>
<span class="f121__mono">{{ d.uin }}</span>
</div>
}
<div class="f121__kv-row">
<span></span>
<span>
<button type="button" class="f121__btn f121__btn--warn" (click)="revokeDeclaration()">
Withdraw declaration
</button>
</span>
</div>
} @else if (fy && !configMissing) {
<div class="f121__kv-row">
<span></span>
<span>
@if (!showDeclarationForm) {
<button type="button" class="f121__btn" (click)="openDeclarationForm()" [disabled]="!panWellFormed">
Record {{ formLabel }} for FY {{ fy }}
</button>
@if (!panWellFormed) {
<div class="muted">A valid PAN must be on the KYC first — a declaration without one is void.</div>
}
}
</span>
</div>
@if (showDeclarationForm) {
<div class="f121__decl-form">
<label>
<span>Accepted on *</span>
<input type="date" [(ngModel)]="newDeclaration.acceptedOn" />
</label>
<label>
<span>UIN</span>
<input type="text" [(ngModel)]="newDeclaration.uin" placeholder="Allotted on acceptance" />
</label>
<label>
<span>Estimated income for the year (₹)</span>
<input type="number" min="0" [(ngModel)]="newDeclaration.estimatedIncome" />
</label>
@if (declarationError) {
<div class="f121__note f121__note--warn">
<mat-icon>error_outline</mat-icon>
<div>{{ declarationError }}</div>
</div>
}
<div class="f121__decl-actions">
<button type="button" class="f121__btn" (click)="showDeclarationForm = false">Cancel</button>
<button
type="button"
class="f121__btn f121__btn--primary"
(click)="saveDeclaration()"
[disabled]="savingDeclaration"
>
{{ savingDeclaration ? 'Saving…' : 'Save declaration' }}
</button>
</div>
</div>
}
}
@for (old of declarations; track old.id) {
@if (old.revoked && old.fy === fy) {
<div class="f121__kv-row f121__kv-row--muted">
<span>Withdrawn</span>
<span>
Form {{ old.formType }} accepted {{ old.acceptedOn | date: 'd MMM y' }}, withdrawn
{{ old.revokedOn | date: 'd MMM y' }} — {{ old.revokeReason }}
</span>
</div>
}
@if (declarationOnRecord && !acceptanceDateKnown) {
<div class="f121__kv-row f121__kv-row--muted">
<span>Acceptance date</span>
<span>
Not captured — the declaration is treated as covering the whole year. A mid-year acceptance would change
the figures below.
</span>
</div>
}
@if (eligibility) {
@for (reason of eligibility.reasons; track reason) {
@@ -332,61 +332,3 @@
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-weight: 600;
}
/* ── Recording a declaration ── */
.f121__btn {
padding: 6px 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
font: inherit;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
&--primary {
border-color: #1d4ed8;
background: #1d4ed8;
color: #fff;
}
&--warn {
border-color: #b45309;
color: #b45309;
}
}
.f121__decl-form {
display: grid;
gap: 10px;
padding: 12px 16px;
label {
display: grid;
gap: 4px;
font-size: 12px;
span {
color: #6b7280;
font-weight: 600;
}
input {
max-width: 280px;
padding: 6px 8px;
border: 1px solid #d1d5db;
border-radius: 6px;
font: inherit;
}
}
}
.f121__decl-actions {
display: flex;
gap: 8px;
}
@@ -23,8 +23,6 @@ import { SavingsService } from 'app/savings/savings.service';
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { TdsDeclarationRecord, TdsReportsService } from 'app/tds-reports/tds-reports.service';
import { TdsDeductee } from 'app/tds-reports/tds-reports-data';
/** Domain logic */
import {
@@ -113,7 +111,6 @@ export class Form121TabComponent implements OnInit, OnDestroy {
private savingsService = inject(SavingsService);
private fixedDepositsService = inject(FixedDepositsService);
private recurringDepositsService = inject(RecurringDepositsService);
private tdsService = inject(TdsReportsService);
private destroy$ = new Subject<void>();
@@ -127,25 +124,23 @@ export class Form121TabComponent implements OnInit, OnDestroy {
pan = '';
panWellFormed = false;
// ── Declarations on record ──
/**
* Every declaration the customer has given, each for one year and from the day the branch
* accepted it. The KYC record only ever held a type and a year, which made a September
* declaration shield interest back to April and let last year's form run on.
*/
declarations: TdsDeclarationRecord[] = [];
// ── Declaration on record ──
declarationOnRecord = false;
/**
* The form actually in force for the selected year. From FY 2026-27 a single
* Form 121 replaced the 15G/15H split, so this is derived from the year rather
* than from the payee's age.
*/
declarationForm: DeclarationForm = '121';
// ── Recording a declaration ──
showDeclarationForm = false;
savingDeclaration = false;
declarationError = '';
newDeclaration = { acceptedOn: '', uin: '', estimatedIncome: null as number | null };
/** Kind recorded at KYC, meaningful only for a pre-2026 year. */
declarationKind: '15G' | '15H' = '15G';
declarationFy = '';
/**
* The KYC step records only the year, not the date the branch accepted the
* declaration, so it has to be treated as covering the whole year. Surfaced on
* screen rather than hidden — a mid-year acceptance would change the figures.
*/
acceptanceDateKnown = false;
eligibility: EligibilityResult | null = null;
@@ -162,9 +157,9 @@ export class Form121TabComponent implements OnInit, OnDestroy {
configMissing = false;
/** Raw account inputs, kept so the year selector can recompute cheaply. */
private accountInputs: (TdsAccountInput & { meta: Form121AccountRow; taxLines: InterestCredit[] })[] = [];
/** This customer's FD/RD interest received in savings, and TDS taken there, per year. */
private platformByFy = new Map<string, TdsDeductee | null>();
private accountInputs: (TdsAccountInput & { meta: Form121AccountRow })[] = [];
/** Withheld-tax postings by FY, from the ledger. */
private actualTdsByFy = new Map<string, number>();
ngOnInit(): void {
this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => {
@@ -187,15 +182,13 @@ export class Form121TabComponent implements OnInit, OnDestroy {
forkJoin({
client: this.clientsService.getClientData(this.clientId, true).pipe(catchError(() => of(null))),
kyc: this.clientsService.getClientKyc(this.clientId, true).pipe(catchError(() => of(null))),
accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null))),
declarations: this.tdsService.getDeclarations(Number(this.clientId)).pipe(catchError(() => of([])))
accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null)))
})
.pipe(
takeUntil(this.destroy$),
switchMap(({ client, kyc, accounts, declarations }) => {
switchMap(({ client, kyc, accounts }) => {
this.applyClient(client);
this.applyKyc(kyc);
this.declarations = declarations || [];
const deposits = this.depositAccountsOf(accounts);
return deposits.length ? forkJoin(deposits.map((a) => this.fetchAccount(a))) : of([] as any[]);
}),
@@ -219,8 +212,12 @@ export class Form121TabComponent implements OnInit, OnDestroy {
private applyKyc(kyc: any): void {
this.pan = (kyc?.panNumber ?? '').toUpperCase();
this.panWellFormed = isWellFormedPan(this.pan);
this.declarationOnRecord = !!kyc?.form15Applicable;
this.declarationKind = kyc?.form15Type === '15H' ? '15H' : '15G';
this.declarationForm =
this.fy && FY_TAX_CONFIG[normaliseFy(this.fy)] ? declarationFormFor(this.fy, this.dateOfBirth) : '121';
this.declarationFy = kyc?.form15FY ?? '';
this.acceptanceDateKnown = false;
}
/** Savings, FD and RD accounts — everything that can credit interest. */
@@ -254,7 +251,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
*/
private ingest(details: any[]): void {
this.accountInputs = [];
this.platformByFy = new Map<string, TdsDeductee | null>();
this.actualTdsByFy = new Map<string, number>();
const fys = new Set<string>();
const clientIdNum = Number(this.clientId);
@@ -275,7 +272,6 @@ export class Form121TabComponent implements OnInit, OnDestroy {
};
const credits: InterestCredit[] = [];
const taxLines: InterestCredit[] = [];
let tdsDeducted = 0;
for (const t of d.transactions ?? []) {
@@ -293,7 +289,8 @@ export class Form121TabComponent implements OnInit, OnDestroy {
} else if (t.transactionType?.withholdTax) {
const amount = t.amount ?? 0;
tdsDeducted += amount;
taxLines.push({ date: iso, amount, accountNo: ref.accountNo });
const txnFy = fyOf(iso);
this.actualTdsByFy.set(txnFy, (this.actualTdsByFy.get(txnFy) ?? 0) + amount);
}
}
@@ -316,12 +313,12 @@ export class Form121TabComponent implements OnInit, OnDestroy {
deducteeClientId: deductee
};
this.accountInputs.push({ ...ref, credits, taxLines, meta });
this.accountInputs.push({ ...ref, credits, meta });
}
this.availableFys = [...fys].sort().reverse();
if (!this.availableFys.includes(this.fy)) {
this.fy = this.availableFys[0] ?? this.declarations[0]?.fy ?? Object.keys(FY_TAX_CONFIG).sort().reverse()[0] ?? '';
this.fy = this.availableFys[0] ?? (this.declarationFy ? normaliseFy(this.declarationFy) : '');
}
}
@@ -341,35 +338,11 @@ export class Form121TabComponent implements OnInit, OnDestroy {
const year = normaliseFy(this.fy);
this.configMissing = !FY_TAX_CONFIG[year];
// TDS is tracked on FD/RD interest once it reaches savings, and deducted there. Those figures live
// on the platform, not in the deposit's own transactions, so they are fetched once per year and
// the computation waits for them.
if (!this.platformByFy.has(year)) {
this.loadPlatformYear(year);
return;
}
const platform = this.platformByFy.get(year);
for (const a of this.accountInputs) {
if (!a.meta.tdsApplicable) {
continue;
}
const match = platform?.accounts.find((p) => p.accountNo === a.accountNo);
a.credits = match?.credits ?? [];
a.taxLines = match?.deductions ?? [];
}
// Per-account interest for the selected year, for the three lists.
for (const a of this.accountInputs) {
a.meta.interest = a.credits.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0);
// The selected year only - this used to total every year the account had ever been taxed in.
a.meta.tdsDeducted = a.meta.tdsApplicable
? a.taxLines.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0)
: 0;
}
// What was actually debited for this customer as deductee, wherever the savings account sits.
this.actualTds = this.accountInputs
.filter((a) => a.meta.tdsApplicable)
.reduce((s, a) => s + a.meta.tdsDeducted, 0);
this.actualTds = this.actualTdsByFy.get(year) ?? 0;
if (!this.dateOfBirth || this.configMissing) {
return;
@@ -409,95 +382,21 @@ export class Form121TabComponent implements OnInit, OnDestroy {
return applicable.some((a) => a.meta.mode === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
}
private loadPlatformYear(year: string): void {
this.tdsService
.getDepositInterest(Number(year.slice(0, 4)), Number(this.clientId))
.pipe(
catchError(() => of([])),
takeUntil(this.destroy$)
)
.subscribe((rows) => {
this.platformByFy.set(year, rows.find((r) => `${r.clientId}` === `${this.clientId}`) ?? null);
this.recompute();
});
}
/** The live declaration recorded for the year being shown, if any. */
get declarationRecord(): TdsDeclarationRecord | null {
if (!this.fy) {
/** The declaration, but only when it was filed for the year being shown. */
private declarationForSelectedFy(): Declaration | null {
if (!this.declarationOnRecord || !this.fy) {
return null;
}
const year = normaliseFy(this.fy);
return this.declarations.find((d) => d.fy === year && !d.revoked) ?? null;
}
get declarationOnRecord(): boolean {
return !!this.declarationRecord;
}
/** The declaration as the engine takes it — operative from the day it was accepted. */
private declarationForSelectedFy(): Declaration | null {
const d = this.declarationRecord;
if (!d) {
if (this.declarationFy && normaliseFy(this.declarationFy) !== year) {
return null;
}
const kind = d.formType === '15H' ? '15H' : d.formType === '15G' ? '15G' : formKindFor(this.dateOfBirth, d.fy);
return { kind, acceptedOn: d.acceptedOn, fy: d.fy, uin: d.uin || undefined };
}
openDeclarationForm(): void {
this.newDeclaration = { acceptedOn: new Date().toISOString().slice(0, 10), uin: '', estimatedIncome: null };
this.declarationError = '';
this.showDeclarationForm = true;
}
saveDeclaration(): void {
if (!this.newDeclaration.acceptedOn) {
this.declarationError = 'Enter the date the branch accepted the form.';
return;
}
this.savingDeclaration = true;
this.declarationError = '';
this.tdsService
.createDeclaration({
clientId: Number(this.clientId),
fy: normaliseFy(this.fy),
formType: declarationFormFor(this.fy, this.dateOfBirth),
acceptedOn: this.newDeclaration.acceptedOn,
uin: this.newDeclaration.uin || undefined,
estimatedIncome: this.newDeclaration.estimatedIncome
})
.subscribe({
next: (rows) => {
this.savingDeclaration = false;
this.showDeclarationForm = false;
this.declarations = rows || [];
this.recompute();
},
error: (err: any) => {
this.savingDeclaration = false;
this.declarationError =
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Could not save.';
}
});
}
revokeDeclaration(): void {
const d = this.declarationRecord;
if (!d) {
return;
}
const reason = prompt('Why is this declaration being withdrawn? From today, TDS will be deducted again.');
if (!reason) {
return;
}
this.tdsService.revokeDeclaration(d.id, reason).subscribe({
next: (rows) => {
this.declarations = rows || [];
this.recompute();
},
error: (err: any) => alert(err?.error?.defaultUserMessage || 'Could not revoke the declaration.')
});
return {
kind: this.declarationKind,
// No acceptance date on record — treated as covering the whole year.
acceptedOn: `${year.slice(0, 4)}-04-01`,
fy: year
};
}
// ───────────────────────── Template helpers ─────────────────────────
@@ -575,12 +474,11 @@ export class Form121TabComponent implements OnInit, OnDestroy {
}
/**
* A declaration is on record but a deposit still carries a stock Fineract tax group. Those
* accounts withhold on their own rules and never consult the declaration record, so the form
* does not stop their deduction until the tax group is removed.
* A declaration is on record but deposit accounts are still set to withhold —
* the branch has not switched them off.
*/
get declarationNotAppliedToAccounts(): boolean {
return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => !!a.taxGroup);
return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => a.withHoldTax);
}
/**
-4
View File
@@ -36,10 +36,6 @@ export class CacheInterceptor implements HttpInterceptor {
'/rbireserveconfig',
'/tdsconfig',
'/tds/deposit-interest',
// Declarations and challans are entered on the very screens that read them back.
'/tds/declarations',
'/tds/challans',
'/tds/deducted',
'/savingsinterestpostingreports',
// Cash in vaults and drawers changes with every transaction; a cached teller list or position
// kept showing a branch's old tellers and figures at day close.
+5 -27
View File
@@ -67,18 +67,12 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
// endpoint isn't available on a given backend deployment, the client-general-step
// component already falls back to riskRules = [] silently; no toast needed here either.
const isKycRiskRulesRequest = request.url.includes('/kyc/risk-rules');
// A document's own file can be missing from the content store (a database copied between
// environments without its file store is the usual cause) even though its m_document row
// survives — the backend reports that as a 404, same as a client with no photo, and every
// caller that fetches a signature/attachment image already recovers with its own catchError.
const isDocumentAttachmentRequest = request.url.match(/\/documents\/\d+\/attachment$/);
const isSilentRequest =
isClientImageRequest ||
isClientKycRequest ||
isDirectorRequest ||
isClientPendingEditRequest ||
isKycRiskRulesRequest ||
isDocumentAttachmentRequest;
isKycRiskRulesRequest;
// Explicit per-request opt-out (e.g. per-row enrichment fetches on a list page whose
// component already recovers via its own catchError) — silenced for every status, since
@@ -99,8 +93,8 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
throw response;
}
// Resolved once and emitted in one place below — see the severity decision there for which of
// these surfaces as a centred modal versus a corner toast.
// Resolved once and emitted in one place below, so every failure here is consistently tagged
// as an error and therefore surfaces as a centred modal rather than a corner toast.
let alert: { type: string; message: string } | null = null;
if (status === 401 || (environment.oauth.enabled && status === 400)) {
alert = { type: 'Authentication Error', message: 'Invalid User Details. Please try again!' };
@@ -125,13 +119,7 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
message: errorMessage || 'This action is not allowed in the current state.'
};
} else if (status === 500) {
// errorMessage carries the backend's actual explanation (defaultUserMessage/developerMessage)
// when one is present; the generic sentence is only a last resort for a response with no body
// at all (a proxy timeout, a stack trace the server never wrapped into JSON).
alert = {
type: 'Internal Server Error',
message: errorMessage || 'Internal Server Error. Please try again later.'
};
alert = { type: 'Internal Server Error', message: 'Internal Server Error. Please try again later.' };
} else if (status === 501) {
alert = {
type: this.translate.instant('error.resource.notImplemented.type'),
@@ -145,17 +133,7 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
}
if (alert) {
// A GET is the page reading data it did not ask the user for, in the background — a photo,
// an attachment, a notes list, a standing-instructions panel. Every one of those already has
// its own catchError fallback at the call site (see transaction-entry.component.ts and
// similar account-overview loaders), so the page keeps working regardless. Blocking the whole
// screen on a dialog the user has to click "OK" on, for data that was never more than a
// secondary panel, is the "irritate na ho" complaint this guards against — it goes to the
// corner toast instead, same place a non-error alert already goes. A write (POST/PUT/PATCH/
// DELETE) is different: that is the one action the user was waiting on, so its failure still
// needs the centred, must-acknowledge dialog.
const isBackgroundRead = request.method === 'GET' && (status === 500 || status === 501 || status === 503);
this.alertService.alert({ ...alert, severity: isBackgroundRead ? undefined : 'error' });
this.alertService.alert({ ...alert, severity: 'error' });
}
throw response;
+369 -275
View File
@@ -503,7 +503,8 @@
[matMenuTriggerFor]="loanOriginationMenu"
*mifosxHasPermission="'MENU_LOAN_ORIGINATION'"
>
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon><span class="ni-full">Loan Origination</span
><span class="ni-short">Loans</span>
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a
@@ -512,7 +513,8 @@
(click)="glMenuOpen = !glMenuOpen; subLedgerOpen = false"
*mifosxHasPermission="'MENU_GENERAL_LEDGER'"
>
<fa-icon icon="book-open" class="ni-ico"></fa-icon>General Ledger
<fa-icon icon="book-open" class="ni-ico"></fa-icon><span class="ni-full">General Ledger</span
><span class="ni-short">GL</span>
<fa-icon [icon]="glMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
<a
@@ -521,7 +523,8 @@
(click)="subLedgerOpen = !subLedgerOpen; glMenuOpen = false"
*mifosxHasPermission="'MENU_SUB_LEDGER'"
>
<fa-icon icon="book" class="ni-ico"></fa-icon>Sub Ledger
<fa-icon icon="book" class="ni-ico"></fa-icon><span class="ni-full">Sub Ledger</span
><span class="ni-short">Sub GL</span>
<fa-icon [icon]="subLedgerOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
</div>
@@ -583,14 +586,16 @@
[matMenuTriggerFor]="reconciliationMenu"
*mifosxHasPermission="'MENU_CONFIG_WIZARD'"
>
<fa-icon icon="sync-alt" class="ni-ico"></fa-icon>Reconciliation
<fa-icon icon="sync-alt" class="ni-ico"></fa-icon><span class="ni-full">Reconciliation</span
><span class="ni-short">Recon</span>
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" routerLink="/chequebook" *mifosxHasPermission="'MENU_CHEQUEBOOK'">
<fa-icon icon="book" class="ni-ico"></fa-icon>Cheque Book
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="letterMgmtMenu" *mifosxHasPermission="'MENU_LETTER_MGMT'">
<fa-icon icon="envelope" class="ni-ico"></fa-icon>Documents Hub
<fa-icon icon="envelope" class="ni-ico"></fa-icon><span class="ni-full">Documents Hub</span
><span class="ni-short">Docs Hub</span>
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
</div>
@@ -617,77 +622,89 @@
</button>
</mat-menu>
<mat-menu #reportsMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item [routerLink]="['/branch-ops/account-register']" tabindex="0">
<i class="fa fa-book menu-item-icon"></i> Account Register
</button>
<button mat-menu-item [routerLink]="['/reports']" tabindex="0">
<i class="fa fa-th-list menu-item-icon"></i> All Reports
</button>
<button mat-menu-item [routerLink]="['/reports', 'Client']" tabindex="0">
<i class="fa fa-user menu-item-icon"></i> Clients
</button>
<button mat-menu-item [routerLink]="['/reports', 'Loan']" tabindex="0">
<i class="fa fa-credit-card menu-item-icon"></i> Loans
</button>
<button mat-menu-item [routerLink]="['/reports', 'Savings']" tabindex="0">
<i class="fa fa-piggy-bank menu-item-icon"></i> Savings
</button>
<button mat-menu-item [routerLink]="['/reports', 'Fund']" tabindex="0">
<i class="fa fa-hand-holding-usd menu-item-icon"></i> Funds
</button>
<button mat-menu-item [routerLink]="['/reports', 'Accounting']" tabindex="0">
<i class="fa fa-calculator menu-item-icon"></i> Accounting
</button>
<mat-divider></mat-divider>
<!-- Listed here as well as under Branch Ops: an officer looking for the day's figures reaches
for Reports, and the same screen answers both. -->
<button mat-menu-item routerLink="/branch-ops/day-book" tabindex="0">
<i class="fa fa-book menu-item-icon"></i> Day Book &amp; Officer Verification
</button>
<button mat-menu-item routerLink="/branch-ops/end-of-day" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> End of the Day Report
</button>
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
<i class="fa fa-file-invoice menu-item-icon"></i> Form 97/98 Transaction Report
</button>
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
</button>
<button mat-menu-item routerLink="/reports/kyc-expiry" tabindex="0">
<i class="fa fa-id-card menu-item-icon"></i> Re-KYC Expiry &amp; Outreach
</button>
<button mat-menu-item routerLink="/reports/dormancy" tabindex="0">
<i class="fa fa-hourglass-half menu-item-icon"></i> Inactive, Dormant &amp; DEAF
</button>
<button mat-menu-item routerLink="/reports/savings-interest" tabindex="0">
<i class="fa fa-percentage menu-item-icon"></i> Savings Interest Posted
</button>
<button mat-menu-item routerLink="/reports/interest-posting" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> Interest Posting Reports
</button>
<button mat-menu-item routerLink="/reports/interest-posting-summary" tabindex="0">
<i class="fa fa-table menu-item-icon"></i> Interest Posting Summary (Branch-wise)
</button>
<!--
The Principal Officer clears flagged UNSC screenings here. It is not a runnable report,
so it never shows up under the report categories above and had no nav entry at all.
-->
<button mat-menu-item routerLink="/reports/sanctions-logs" tabindex="0">
<i class="fa fa-shield-alt menu-item-icon"></i> Sanctions Screening &amp; Alerts
</button>
<button mat-menu-item routerLink="/reports/dip-lookups" tabindex="0">
<i class="fa fa-satellite-dish menu-item-icon"></i> DIP / MNRL Mobile Lookups
</button>
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
<i class="fa fa-percent menu-item-icon"></i> Deposit Interest &amp; TDS
</button>
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
<i class="fa fa-landmark menu-item-icon"></i> TDS Remitted to Government
</button>
<button mat-menu-item routerLink="/reports/bor" tabindex="0">
<i class="fa fa-gavel menu-item-icon"></i> BOR Register &mdash; Share Capital
</button>
<mat-menu #reportsMenu="matMenu" class="reports-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid reports-grid" (click)="$event.stopPropagation()">
<!-- Core Reports -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Core Reports</div>
<button mat-menu-item [routerLink]="['/branch-ops/account-register']" tabindex="0">
<i class="fa fa-book menu-item-icon"></i> Account Register
</button>
<button mat-menu-item [routerLink]="['/reports']" tabindex="0">
<i class="fa fa-th-list menu-item-icon"></i> All Reports
</button>
<button mat-menu-item [routerLink]="['/reports', 'Client']" tabindex="0">
<i class="fa fa-user menu-item-icon"></i> Clients
</button>
<button mat-menu-item [routerLink]="['/reports', 'Loan']" tabindex="0">
<i class="fa fa-credit-card menu-item-icon"></i> Loans
</button>
<button mat-menu-item [routerLink]="['/reports', 'Savings']" tabindex="0">
<i class="fa fa-piggy-bank menu-item-icon"></i> Savings
</button>
<button mat-menu-item [routerLink]="['/reports', 'Fund']" tabindex="0">
<i class="fa fa-hand-holding-usd menu-item-icon"></i> Funds
</button>
<button mat-menu-item [routerLink]="['/reports', 'Accounting']" tabindex="0">
<i class="fa fa-calculator menu-item-icon"></i> Accounting
</button>
</div>
<!-- Operations & Interest -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Operations &amp; Interest</div>
<button mat-menu-item routerLink="/branch-ops/day-book" tabindex="0">
<i class="fa fa-book menu-item-icon"></i> Day Book &amp; Officer Verification
</button>
<button mat-menu-item routerLink="/branch-ops/end-of-day" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> End of the Day Report
</button>
<button mat-menu-item routerLink="/reports/savings-interest" tabindex="0">
<i class="fa fa-percentage menu-item-icon"></i> Savings Interest Posted
</button>
<button mat-menu-item routerLink="/reports/interest-posting" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> Interest Posting Reports
</button>
<button mat-menu-item routerLink="/reports/fd-interest-posting" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> FD Interest Posting Reports
</button>
<button mat-menu-item routerLink="/reports/interest-posting-summary" tabindex="0">
<i class="fa fa-table menu-item-icon"></i> Interest Posting Summary (Branch-wise)
</button>
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
<i class="fa fa-percent menu-item-icon"></i> Deposit Interest &amp; TDS
</button>
</div>
<!-- Compliance & Registers -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Compliance &amp; Registers</div>
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
<i class="fa fa-file-invoice menu-item-icon"></i> Form 97/98 Transaction Report
</button>
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
</button>
<button mat-menu-item routerLink="/reports/kyc-expiry" tabindex="0">
<i class="fa fa-id-card menu-item-icon"></i> Re-KYC Expiry &amp; Outreach
</button>
<button mat-menu-item routerLink="/reports/dormancy" tabindex="0">
<i class="fa fa-hourglass-half menu-item-icon"></i> Inactive, Dormant &amp; DEAF
</button>
<button mat-menu-item routerLink="/reports/sanctions-logs" tabindex="0">
<i class="fa fa-shield-alt menu-item-icon"></i> Sanctions Screening &amp; Alerts
</button>
<button mat-menu-item routerLink="/reports/dip-lookups" tabindex="0">
<i class="fa fa-satellite-dish menu-item-icon"></i> DIP / MNRL Mobile Lookups
</button>
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
<i class="fa fa-landmark menu-item-icon"></i> TDS Remitted to Government
</button>
<button mat-menu-item routerLink="/reports/bor" tabindex="0">
<i class="fa fa-gavel menu-item-icon"></i> BOR Register &mdash; Share Capital
</button>
</div>
</div>
</mat-menu>
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
@@ -1060,121 +1077,146 @@
<!-- Sub Menu Dropdowns -->
<!-- Branch-level operations. Starts with the day register; other branch-wide controls belong
here rather than under Cashier, which is one role's workspace. -->
<mat-menu #branchOpsMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/day-register']">
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> Day Open / Day Close
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/missed-close']">
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Missed Close Register
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/vault-position']">
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Vault Position
</button>
<!-- The day book carries the Officer Verification Sheet — each person's credit, debit and net
for the day. It was reachable only from the Clerk and Cashier menus, which is the one
place a principal officer signing the day off does not look. -->
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book &amp; Verification
</button>
<!-- The signed sheet, as against the day book's working list: the day's modes, the drawer
against the books, and what is still open. This is what goes in the file. -->
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/end-of-day']">
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> End of the Day Report
</button>
<mat-menu #branchOpsMenu="matMenu" class="branch-ops-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid branch-ops-grid" (click)="$event.stopPropagation()">
<div class="deposit-menu-group">
<div class="deposit-group-title">Day Operations</div>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/day-register']">
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> Day Open / Day Close
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/missed-close']">
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Missed Close Register
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/vault-position']">
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Vault Position
</button>
</div>
<div class="deposit-menu-group">
<div class="deposit-group-title">Verification &amp; Reports</div>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book &amp; Verification
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/end-of-day']">
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> End of the Day Report
</button>
</div>
</div>
</mat-menu>
<!-- Clerk was a bare link straight into the fullscreen dashboard, which left the clerk's other
screens reachable only from inside that fullscreen nav. Anything a clerk needs from the
normal shell — an open voucher exception above all — belongs here. -->
<mat-menu #clerkMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" routerLink="/clerk/dashboard" (click)="hideShellLayout = true">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Clerk Dashboard
</button>
<!-- The clerk reads the day as a sheet to sign off, not as a working list of vouchers: the
day's summary, the counter counted against the books, and what is still open. The day
book's own row-by-row view stays reachable from Branch Ops and from the Cashier menu. -->
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/end-of-day']">
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> End of the Day Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/withdrawal-requests']">
<fa-icon icon="money-bill-wave" class="menu-item-icon"></fa-icon> My Withdrawal Requests
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/my-disbursement-requests']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Disbursements &amp; Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/dividend-dashboard']">
<fa-icon icon="chart-pie" class="menu-item-icon"></fa-icon> Dividend Dashboard
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/my-work']">
<fa-icon icon="user-check" class="menu-item-icon"></fa-icon> My Vouchers
</button>
<!-- Sits under My Vouchers because it answers the same question for the one worker who has no
own list: the system. A nightly run belongs to nobody, so it appears on no person's page. -->
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/system-vouchers']">
<fa-icon icon="robot" class="menu-item-icon"></fa-icon> System Vouchers
</button>
<!-- A clerk supervises the counter, so they need to see what each cashier is holding and what
went through their drawer. Both screens already return every cashier for a non-cashier
caller (CashierRoleHelper scopes only a cashier to themselves) — they were simply not
reachable from this menu. -->
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cashier-position']">
<fa-icon icon="wallet" class="menu-item-icon"></fa-icon> Cashier Position
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/closing-report']">
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Closing Cash Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/received-report']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Received Vouchers
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/payment-report']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Payment Vouchers
</button>
<!-- Clerk Desk -->
<mat-menu #clerkMenu="matMenu" class="clerk-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid clerk-grid" (click)="$event.stopPropagation()">
<!-- Clerk Desk -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Clerk Desk</div>
<button mat-menu-item tabindex="0" routerLink="/clerk/dashboard" (click)="hideShellLayout = true">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Clerk Dashboard
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/withdrawal-requests']">
<fa-icon icon="money-bill-wave" class="menu-item-icon"></fa-icon> My Withdrawal Requests
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/my-disbursement-requests']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Disbursements &amp; Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/dividend-dashboard']">
<fa-icon icon="chart-pie" class="menu-item-icon"></fa-icon> Dividend Dashboard
</button>
</div>
<!-- Vouchers -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Vouchers</div>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/my-work']">
<fa-icon icon="user-check" class="menu-item-icon"></fa-icon> My Vouchers
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/system-vouchers']">
<fa-icon icon="robot" class="menu-item-icon"></fa-icon> System Vouchers
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/received-report']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Received Vouchers
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/payment-report']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Payment Vouchers
</button>
</div>
<!-- Counter & Reports -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Counter &amp; Reports</div>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cashier-position']">
<fa-icon icon="wallet" class="menu-item-icon"></fa-icon> Cashier Position
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/closing-report']">
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Closing Cash Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/end-of-day']">
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> End of the Day Report
</button>
</div>
</div>
</mat-menu>
<mat-menu #cashierMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-exchange']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Cash Exchange Entry
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/system/denomination-master']">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Cash Denomination
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/closing-report']">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Closing Cash Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/exchange-detail']">
<fa-icon icon="globe" class="menu-item-icon"></fa-icon> Currency Exchange Detail
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-transfer']">
<fa-icon icon="random" class="menu-item-icon"></fa-icon> Cash Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/excess-report']">
<fa-icon icon="exclamation-circle" class="menu-item-icon"></fa-icon> Excess Transaction Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-settlement']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Cash Settlement
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/inter-cashier']">
<fa-icon icon="exchange-alt" class="menu-item-icon"></fa-icon> Inter-Cashier Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cashier-position']">
<fa-icon icon="wallet" class="menu-item-icon"></fa-icon> Cashier Position
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/reconciliation']">
<fa-icon icon="balance-scale" class="menu-item-icon"></fa-icon> Reconciliation
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-history']">
<fa-icon icon="history" class="menu-item-icon"></fa-icon> Cash History
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']">
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
</button>
<!-- Cashier Desk -->
<mat-menu #cashierMenu="matMenu" class="cashier-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid cashier-grid" (click)="$event.stopPropagation()">
<!-- Cash Entries & Exchange -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Cash &amp; Counter</div>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-exchange']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Cash Exchange Entry
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/exchange-detail']">
<fa-icon icon="globe" class="menu-item-icon"></fa-icon> Currency Exchange Detail
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']">
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/system/denomination-master']">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Cash Denomination
</button>
</div>
<!-- Transfers & Settlement -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Transfers &amp; Settlement</div>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-transfer']">
<fa-icon icon="random" class="menu-item-icon"></fa-icon> Cash Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/inter-cashier']">
<fa-icon icon="exchange-alt" class="menu-item-icon"></fa-icon> Inter-Cashier Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-settlement']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Cash Settlement
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cashier-position']">
<fa-icon icon="wallet" class="menu-item-icon"></fa-icon> Cashier Position
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
</button>
</div>
<!-- Reports & Audit -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Reports &amp; Audit</div>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/closing-report']">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Closing Cash Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/excess-report']">
<fa-icon icon="exclamation-circle" class="menu-item-icon"></fa-icon> Excess Transaction Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/reconciliation']">
<fa-icon icon="balance-scale" class="menu-item-icon"></fa-icon> Reconciliation
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-history']">
<fa-icon icon="history" class="menu-item-icon"></fa-icon> Cash History
</button>
</div>
</div>
</mat-menu>
<mat-menu #depositsMenu="matMenu" class="deposits-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid" (click)="$event.stopPropagation()">
<div class="deposit-menu-grid deposits-grid" (click)="$event.stopPropagation()">
<div class="deposit-menu-group">
<div class="deposit-group-title">Accounts</div>
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/savings']">
@@ -1236,34 +1278,54 @@
</div>
</mat-menu>
<mat-menu #clearingMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/inward']">
<fa-icon icon="arrow-left" class="menu-item-icon"></fa-icon> Inward Clearing (CTS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/outward']">
<fa-icon icon="arrow-right" class="menu-item-icon"></fa-icon> Outward Clearing (CTS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/positive-pay']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Positive Pay (PPS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/settlement']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Settlement
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/reconciliation']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Reconciliation
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/nachclearing']">
<fa-icon icon="money-check" class="menu-item-icon"></fa-icon> NACH Clearing
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="arrow-right" class="menu-item-icon"></fa-icon> Outward</button>
<button mat-menu-item tabindex="0"><fa-icon icon="arrow-left" class="menu-item-icon"></fa-icon> Inward</button>
<button mat-menu-item [routerLink]="['/sponsor-dd/inventory']" tabindex="0">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Sponsor DD Inventory
</button>
<mat-menu #clearingMenu="matMenu" class="clearing-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid clearing-grid" (click)="$event.stopPropagation()">
<!-- CTS Clearing -->
<div class="deposit-menu-group">
<div class="deposit-group-title">CTS Clearing</div>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/inward']">
<fa-icon icon="arrow-left" class="menu-item-icon"></fa-icon> Inward Clearing (CTS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/outward']">
<fa-icon icon="arrow-right" class="menu-item-icon"></fa-icon> Outward Clearing (CTS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/positive-pay']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Positive Pay (PPS)
</button>
</div>
<!-- Settlement & Recon -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Settlement &amp; Recon</div>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/settlement']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Settlement
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/reconciliation']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Reconciliation
</button>
<button mat-menu-item [routerLink]="['/sponsor-dd/inventory']" tabindex="0">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Sponsor DD Inventory
</button>
</div>
<!-- NACH & Other -->
<div class="deposit-menu-group">
<div class="deposit-group-title">NACH &amp; Direct</div>
<button mat-menu-item tabindex="0" [routerLink]="['/nachclearing']">
<fa-icon icon="money-check" class="menu-item-icon"></fa-icon> NACH Clearing
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="arrow-right" class="menu-item-icon"></fa-icon> Outward
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="arrow-left" class="menu-item-icon"></fa-icon> Inward
</button>
</div>
</div>
</mat-menu>
<mat-menu #hrmMenu="matMenu" class="hrm-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid" (click)="$event.stopPropagation()">
<div class="deposit-menu-grid hrm-grid" (click)="$event.stopPropagation()">
<!-- Payroll -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Payroll</div>
@@ -1570,23 +1632,33 @@
</div>
</mat-menu>
<mat-menu #ckycMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item routerLink="/ckyc" tabindex="0">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> CKYC Management
</button>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('REKYC')">
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #be185d"></fa-icon> Re-KYC Report
</button>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('RISK-REVIEW')">
<fa-icon icon="history" class="menu-item-icon" style="color: #be185d"></fa-icon> Risk Review
</button>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('CERSAI')">
<fa-icon icon="shield-alt" class="menu-item-icon" style="color: #be185d"></fa-icon> CERSAI Report
</button>
<mat-divider></mat-divider>
<button mat-menu-item tabindex="0">
<fa-icon icon="database" class="menu-item-icon" style="color: #be185d"></fa-icon> Dump KYC &amp; Loan Data
</button>
<mat-menu #ckycMenu="matMenu" class="ckyc-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid ckyc-grid" (click)="$event.stopPropagation()">
<!-- KYC Operations -->
<div class="deposit-menu-group">
<div class="deposit-group-title">KYC Operations</div>
<button mat-menu-item routerLink="/ckyc" tabindex="0">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> CKYC Management
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="database" class="menu-item-icon" style="color: #be185d"></fa-icon> Dump KYC &amp; Loan Data
</button>
</div>
<!-- Reports & Screening -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Reports &amp; Screening</div>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('REKYC')">
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #be185d"></fa-icon> Re-KYC Report
</button>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('RISK-REVIEW')">
<fa-icon icon="history" class="menu-item-icon" style="color: #be185d"></fa-icon> Risk Review
</button>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('CERSAI')">
<fa-icon icon="shield-alt" class="menu-item-icon" style="color: #be185d"></fa-icon> CERSAI Report
</button>
</div>
</div>
</mat-menu>
<!-- CFCFRMS — single landing page (CfcfrmsShell) hosts the full
@@ -1604,11 +1676,11 @@
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/sundry-dr-cr">
<fa-icon icon="random" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Sundry Dr / Cr Reconciliation
Sundry Dr / Cr
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/pnl-impact">
<fa-icon icon="chart-line" class="menu-item-icon" style="color: #0d9488"></fa-icon>
P&amp;L Impact Reconciliation
P&amp;L Impact
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/gl-reconciliation">
<fa-icon icon="book-open" class="menu-item-icon" style="color: #0d9488"></fa-icon>
@@ -1616,11 +1688,11 @@
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/fixed-assets">
<fa-icon icon="cube" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Fixed Assets Reconciliation
Fixed Assets
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/inter-company">
<fa-icon icon="sitemap" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Inter-Company Reconciliation
Inter-Company
</button>
</div>
@@ -1633,15 +1705,15 @@
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/api-bank-reco">
<fa-icon icon="code-branch" class="menu-item-icon" style="color: #0d9488"></fa-icon>
API Based Bank Reconciliation
API Bank Reco
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/neft-rtgs">
<fa-icon icon="exchange" class="menu-item-icon" style="color: #0d9488"></fa-icon>
NEFT / RTGS Reconciliation
NEFT / RTGS Reco
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/clearing-reco">
<fa-icon icon="hand-holding-usd" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Clearing Reconciliation
Clearing Reco
</button>
</div>
@@ -1654,11 +1726,11 @@
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/three-way-digital">
<fa-icon icon="project-diagram" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Three-way Digital Reconciliation
Three-way Digital
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/nach-reco">
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
NACH Reconciliation
NACH Reco
</button>
</div>
@@ -1667,11 +1739,11 @@
<div class="deposit-group-title">Instruments &amp; Members</div>
<button mat-menu-item [routerLink]="['/sponsor-dd-reconciliation']" tabindex="0">
<fa-icon icon="money-check-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
DD / Pay Order Reconciliation
DD / Pay Order
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/share-member">
<fa-icon icon="coins" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Share Member Reconciliation
Share Member
</button>
</div>
@@ -1847,46 +1919,68 @@
</div>
</mat-menu>
<mat-menu #insuranceMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item routerLink="/dicgc" tabindex="0">
<fa-icon icon="building" class="menu-item-icon" style="color: #c2410c"></fa-icon> DICGC
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="heart" class="menu-item-icon"></fa-icon> PMJBY</button>
<button mat-menu-item tabindex="0"><fa-icon icon="heart" class="menu-item-icon"></fa-icon> PMSBY</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="hand-holding-heart" class="menu-item-icon"></fa-icon> Indemnity
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="home" class="menu-item-icon"></fa-icon> Assets Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="laptop" class="menu-item-icon"></fa-icon> Cyber Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="university" class="menu-item-icon"></fa-icon> Bank Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Loan Insurance
</button>
<mat-menu #insuranceMenu="matMenu" class="insurance-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid insurance-grid" (click)="$event.stopPropagation()">
<!-- Social & Policy Schemes -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Schemes &amp; Social</div>
<button mat-menu-item routerLink="/dicgc" tabindex="0">
<fa-icon icon="building" class="menu-item-icon" style="color: #c2410c"></fa-icon> DICGC
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="heart" class="menu-item-icon"></fa-icon> PMJBY</button>
<button mat-menu-item tabindex="0"><fa-icon icon="heart" class="menu-item-icon"></fa-icon> PMSBY</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="hand-holding-heart" class="menu-item-icon"></fa-icon> Indemnity
</button>
</div>
<!-- Institutional & Loans -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Institutional &amp; Loans</div>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Loan Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="home" class="menu-item-icon"></fa-icon> Assets Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="laptop" class="menu-item-icon"></fa-icon> Cyber Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="university" class="menu-item-icon"></fa-icon> Bank Insurance
</button>
</div>
</div>
</mat-menu>
<mat-menu #digitalMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item routerLink="/digital/neft-rtgs-report" tabindex="0">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> NEFT / RTGS
</button>
<button mat-menu-item routerLink="/digital/imps-report" tabindex="0">
<fa-icon icon="bolt" class="menu-item-icon"></fa-icon> IMPS
</button>
<button mat-menu-item routerLink="/digital/upi-report" tabindex="0">
<fa-icon icon="mobile-alt" class="menu-item-icon"></fa-icon> UPI Reports
</button>
<button mat-menu-item routerLink="/digital/atm-pos-ecom" tabindex="0">
<fa-icon icon="credit-card" class="menu-item-icon"></fa-icon> ATM / POS / ECOM Reports
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="sync" class="menu-item-icon"></fa-icon> NACH</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon"></fa-icon> Mandate
</button>
<mat-menu #digitalMenu="matMenu" class="digital-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid digital-grid" (click)="$event.stopPropagation()">
<!-- Payment Systems -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Payments</div>
<button mat-menu-item routerLink="/digital/neft-rtgs-report" tabindex="0">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> NEFT / RTGS
</button>
<button mat-menu-item routerLink="/digital/imps-report" tabindex="0">
<fa-icon icon="bolt" class="menu-item-icon"></fa-icon> IMPS
</button>
<button mat-menu-item routerLink="/digital/upi-report" tabindex="0">
<fa-icon icon="mobile-alt" class="menu-item-icon"></fa-icon> UPI Reports
</button>
</div>
<!-- Cards & Mandates -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Cards &amp; Mandates</div>
<button mat-menu-item routerLink="/digital/atm-pos-ecom" tabindex="0">
<fa-icon icon="credit-card" class="menu-item-icon"></fa-icon> ATM / POS / ECOM Reports
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="sync" class="menu-item-icon"></fa-icon> NACH</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon"></fa-icon> Mandate
</button>
</div>
</div>
</mat-menu>
<!-- RBI Mega Menu -->
+197 -124
View File
@@ -266,7 +266,7 @@ $color: #e2e4ec;
max-width: 100%;
overflow-x: hidden;
box-sizing: border-box;
padding: 16px 28px;
padding: 10px 18px;
}
}
@@ -282,21 +282,15 @@ $gray-700: #334155;
/* ============================================================
Main header (top 2-row navigation)
--------------------------------------------------------------
Redesigned 2026 — clean, modern enterprise SaaS feel:
• Both rows live on the same white surface separated only by a
hairline; r2 has a barely-there blue-50 wash so frequent vs
secondary menus read at a glance without dark/light contrast.
• Items are now equal-width "tabs" with NO vertical dividers
between them (the old cell borders made it look spreadsheet-y).
• Hover / active = a 3 px brand-coloured TOP bar + brand text +
coloured icon. Reads instantly without colour-flooding the row.
• Icons larger (12.5 px) and full-opacity always — easier to scan
during demos. Caret is subtle and rotates on open.
Compact, modern enterprise SaaS navigation:
• Height reduced to 32px per row (64px total) to save screen space.
• Smooth horizontal scrolling prevents menu items from getting cut off.
• Compact typography, icons and paddings fit all banking modules cleanly.
============================================================ */
.main-header {
background: #fff;
border-bottom: 2.5px solid $brand;
box-shadow: 0 1px 4px rgb(0 0 0 / 7%);
border-bottom: 2px solid $brand;
box-shadow: 0 1px 3px rgb(0 0 0 / 6%);
position: sticky;
/* Sit directly under the sticky toolbar (32px tall) so both stay visible. */
@@ -306,19 +300,19 @@ $gray-700: #334155;
}
.nav-row {
height: 44px;
height: 32px;
display: flex;
align-items: stretch;
overflow: visible;
overflow: auto visible;
position: relative;
/* Outer frame; vertical dividers between cells are drawn as `border-right`
on each `.ni` below. Switched away from the equal-column grid so each
cell can size to its own label — long names (General Ledger, Documents
Hub) no longer overflow and short ones (Approvals, HRM) no longer waste
space. */
border: 1px solid $gray-200;
background: #fff;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
.r2.nav-row {
@@ -334,25 +328,17 @@ $gray-700: #334155;
background: #f8fafc;
}
// ===== Nav Item — modern tab style =====
// Typography spec — same family / size / weight / color for BOTH rows.
// ===== Nav Item — compact modern tab style =====
.ni {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
gap: 2.5px;
height: 100%;
/* Each cell sizes to its own label (min-width: max-content) and grows
to share any leftover row width. Short labels stay compact, long ones
get the room they need — no overlap, no wasted gap. */
flex: 1 0 auto;
min-width: max-content;
padding: 0 14px;
/* Vertical divider between adjacent cells; outer frame is the row's
own border, and the last cell drops its right border below. */
min-width: 0;
padding: 0 4px;
border-right: 1px solid $gray-200;
cursor: pointer;
white-space: nowrap;
@@ -360,11 +346,11 @@ $gray-700: #334155;
'IBM Plex Sans',
-apple-system,
sans-serif;
font-size: 12px;
font-size: 10px;
font-weight: 500;
color: #0a1a2e;
line-height: 1.3;
letter-spacing: 0.01em;
line-height: 1.2;
letter-spacing: -0.015em;
text-decoration: none;
user-select: none;
transition:
@@ -373,17 +359,15 @@ $gray-700: #334155;
box-shadow 0.15s ease;
border-radius: 0;
/* 3px accent strip that fills on hover / active (uses ::before so we
don't reflow the row when toggling). */
&::before {
content: '';
position: absolute;
left: 4px;
right: 4px;
left: 2px;
right: 2px;
top: 0;
height: 3px;
height: 2px;
background: transparent;
border-radius: 0 0 3px 3px;
border-radius: 0 0 2px 2px;
transition: background 0.15s ease;
}
@@ -410,17 +394,19 @@ $gray-700: #334155;
outline-offset: -2px;
}
/* Last cell in a row sits flush against the row's own right edge, so
drop its divider to avoid a double 2 px line. */
&:last-child {
border-right: none;
}
.ni-short {
display: none;
}
}
.ni-ico {
width: 12px;
height: 12px;
font-size: 12px;
width: 9.5px;
height: 9.5px;
font-size: 9.5px;
color: $gray-500;
flex-shrink: 0;
transition: color 0.15s ease;
@@ -432,11 +418,12 @@ $gray-700: #334155;
}
.ni-caret {
width: 7px;
height: 7px;
font-size: 7px;
width: 5px;
height: 5px;
font-size: 5px;
color: $gray-400;
flex-shrink: 0;
margin-left: 0.5px;
transition:
transform 0.18s ease,
color 0.18s ease;
@@ -447,17 +434,89 @@ $gray-700: #334155;
}
}
@media (width <= 1200px) {
@media (width >= 1400px) {
.ni {
padding: 0 8px;
gap: 5px;
padding: 0 6px;
gap: 3.5px;
font-size: 10.8px;
letter-spacing: 0;
}
.ni-ico {
width: 10px;
height: 10px;
font-size: 10px;
}
.ni-caret {
width: 5.5px;
height: 5.5px;
font-size: 5.5px;
margin-left: 1px;
}
}
@media (width <= 980px) {
@media (width <= 1200px) {
.ni {
padding: 0 3px;
gap: 2px;
font-size: 9.8px;
letter-spacing: -0.02em;
}
.ni-ico {
width: 9px;
height: 9px;
font-size: 9px;
}
.ni-caret {
width: 4.5px;
height: 4.5px;
font-size: 4.5px;
}
}
@media (width <= 1080px) {
.ni {
padding: 0 2.5px;
gap: 1.5px;
font-size: 9.4px;
letter-spacing: -0.025em;
.ni-full {
display: none;
}
.ni-short {
display: inline;
}
}
.ni-ico {
width: 8.5px;
height: 8.5px;
font-size: 8.5px;
}
.ni-caret {
width: 4px;
height: 4px;
font-size: 4px;
}
}
@media (width <= 880px) {
.nav-row {
overflow-x: auto;
scrollbar-width: thin;
}
.ni {
flex: 0 0 auto;
padding: 0 6px;
gap: 4px;
gap: 3px;
font-size: 10px;
}
}
@@ -699,21 +758,29 @@ $gray-700: #334155;
// ===== Nav Dropdown Menus =====
::ng-deep .nav-dropdown {
border-radius: 8px !important;
border-radius: 6px !important;
border: 1px solid $gray-200 !important;
box-shadow: 0 6px 24px rgb(0 0 0 / 10%) !important;
min-width: 180px !important;
padding: 4px 0 !important;
margin-top: 2px !important;
box-shadow: 0 4px 16px rgb(0 0 0 / 12%) !important;
min-width: 170px !important;
padding: 3px 0 !important;
margin-top: 1px !important;
.mat-mdc-menu-item {
font-size: 12.5px !important;
font-size: 10.5px !important;
font-weight: 500 !important;
height: 34px !important;
min-height: 34px !important;
padding: 0 14px !important;
height: 25px !important;
min-height: 25px !important;
padding: 0 6px !important;
line-height: 25px !important;
color: $gray-700 !important;
font-family: 'DM Sans', Roboto, sans-serif !important;
font-family: 'IBM Plex Sans', Roboto, sans-serif !important;
white-space: nowrap !important;
.menu-item-icon {
font-size: 10.5px !important;
margin-right: 5px !important;
opacity: 0.8;
}
&:hover {
background: $brand-hover !important;
@@ -728,39 +795,44 @@ $gray-700: #334155;
}
/* Deposits Mega Panel (inside mat-menu overlay) */
.deposit-menu-grid {
::ng-deep .deposit-menu-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px 24px;
padding: 8px 12px;
min-width: 600px;
gap: 4px 12px;
padding: 6px 10px;
min-width: 0;
width: 100%;
box-sizing: border-box;
overflow-x: hidden;
}
.deposit-menu-group {
::ng-deep .deposit-menu-group {
.deposit-group-title {
font-weight: 700;
font-size: 11px;
font-size: 9.5px;
text-transform: uppercase;
letter-spacing: 0.6px;
letter-spacing: 0.5px;
color: #1074b9;
padding: 4px 16px 6px;
padding: 2px 6px 3px;
margin-bottom: 2px;
border-bottom: 2px solid #1074b9;
border-bottom: 1.5px solid #1074b9;
white-space: nowrap;
}
}
/* NPA Mega Panel — 4 columns (Reports / Analysis / Classification / Recovery Management) */
.npa-grid {
grid-template-columns: repeat(4, minmax(200px, 1fr));
min-width: 880px;
max-width: 96vw;
/* NPA Mega Panel — 3 columns */
::ng-deep .npa-grid {
grid-template-columns: repeat(3, minmax(210px, 1fr));
min-width: 0;
max-width: 100%;
overflow-x: hidden;
}
/* Reconciliation Mega Panel — 5 columns, teal accent to match the icon palette */
.reconciliation-grid {
grid-template-columns: repeat(5, minmax(190px, 1fr));
min-width: 1040px;
max-width: 96vw;
/* Reconciliation Mega Panel — 5 columns */
::ng-deep .reconciliation-grid {
grid-template-columns: repeat(5, minmax(170px, 1fr));
min-width: 0;
max-width: 100%;
overflow-x: hidden;
.deposit-group-title {
color: #0d9488;
@@ -780,47 +852,49 @@ $gray-700: #334155;
.gl-mega-menu {
position: absolute;
top: 100px;
top: 96px;
left: 50%;
transform: translateX(-50%);
background: #fff;
width: 860px;
max-width: 95vw;
padding: 20px 24px;
box-shadow: 0 6px 24px rgb(0 0 0 / 22%);
border-radius: 0 0 10px 10px;
width: 760px;
max-width: 94vw;
max-height: 72vh;
overflow-y: auto;
padding: 12px 16px;
box-shadow: 0 6px 20px rgb(0 0 0 / 18%);
border-radius: 0 0 8px 8px;
z-index: 1000;
border-top: 3px solid #0d47a1;
border-top: 2.5px solid #0d47a1;
}
.gl-mega-menu-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
padding-bottom: 12px;
gap: 8px;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid #e5e7eb;
span {
font-size: 18px;
font-size: 14px;
font-weight: 700;
color: #0d47a1;
letter-spacing: 0.02em;
letter-spacing: 0.01em;
}
}
.gl-mega-menu-header-icon {
font-size: 18px;
font-size: 14px;
color: #0d47a1;
}
.gl-search {
width: 100%;
padding: 10px 14px;
margin-bottom: 16px;
padding: 6px 10px;
margin-bottom: 10px;
border: 1px solid #d0d5dd;
border-radius: 6px;
font-size: 14px;
border-radius: 5px;
font-size: 12px;
box-sizing: border-box;
background: #f9fafb;
@@ -828,36 +902,37 @@ $gray-700: #334155;
outline: none;
border-color: #0d47a1;
background: #fff;
box-shadow: 0 0 0 3px rgb(13 71 161 / 10%);
box-shadow: 0 0 0 2px rgb(13 71 161 / 10%);
}
}
.gl-columns {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
gap: 10px 16px;
}
.gl-group-title {
font-weight: 700;
font-size: 11px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.8px;
letter-spacing: 0.6px;
color: #0d47a1;
margin-bottom: 10px;
padding-bottom: 6px;
border-bottom: 2px solid #0d47a1;
margin-bottom: 5px;
padding-bottom: 3px;
border-bottom: 1.5px solid #0d47a1;
}
.gl-link {
display: flex;
align-items: center;
padding: 7px 6px;
padding: 3.5px 5px;
text-decoration: none;
color: #344054;
font-size: 13px;
font-size: 11.5px;
line-height: 1.3;
cursor: pointer;
border-radius: 5px;
border-radius: 4px;
transition:
background 0.15s,
color 0.15s;
@@ -873,23 +948,21 @@ $gray-700: #334155;
}
.gl-icon {
width: 18px;
margin-right: 8px;
width: 14px;
margin-right: 5px;
color: #667085;
font-size: 13px;
font-size: 11px;
flex-shrink: 0;
.gl-link:hover & {
color: #0d47a1;
}
}
// Marks a menu link as actually wired to a working screen/dialog (vs a still-unwired
// placeholder) — a small green dot at the end of the row, so a reviewer can tell at a glance
// which items in a mega-menu are functional without having to click every one of them.
.gl-link--live::after {
content: '';
width: 6px;
height: 6px;
width: 5px;
height: 5px;
margin-left: auto;
flex-shrink: 0;
border-radius: 50%;
@@ -899,9 +972,9 @@ $gray-700: #334155;
/* RBI Mega Menu */
.rbi-mega-menu {
width: 1100px;
max-width: 97vw;
max-height: 80vh;
width: 880px;
max-width: 94vw;
max-height: 72vh;
overflow-y: auto;
border-top-color: #b71c1c;
}
@@ -909,7 +982,7 @@ $gray-700: #334155;
.rbi-columns {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
gap: 10px 16px;
}
@keyframes move {
@@ -20,7 +20,7 @@
<div class="t-live"></div>
&nbsp;LIVE
</div>
<div class="t-chip">Branch:&nbsp;<strong>{{ officeLabel }}</strong></div>
<div class="t-chip">Branch:&nbsp;<strong>HO</strong></div>
<div class="t-chip">
Date:&nbsp;<strong>{{ today | date: 'dd MMM yyyy' }}</strong>
</div>
@@ -522,7 +522,7 @@
</mat-menu>
<!-- Application User Help Menu -->
<mat-menu #applicationMenu="matMenu" [overlapTrigger]="false">
<mat-menu #applicationMenu="matMenu" [overlapTrigger]="false" class="t-user-menu" xPosition="before">
<button mat-menu-item (click)="help()" tabindex="0">
<mat-icon matListIcon>
<fa-icon icon="question-circle" size="sm"></fa-icon>
+187 -59
View File
@@ -390,6 +390,103 @@ $icon-hover-border: rgb(255 255 255 / 28%);
max-height: 80vh;
overflow-y: auto;
}
// Compact card per AI module
.ai-card.mat-mdc-menu-item {
display: flex !important;
align-items: flex-start !important;
gap: 10px !important;
padding: 10px !important;
min-height: 52px !important;
height: auto !important;
border-radius: 9px !important;
font-family: inherit !important;
line-height: 1.3 !important;
white-space: normal !important;
overflow: visible !important;
transition: background 0.16s ease !important;
.mat-mdc-menu-item-text,
.mdc-list-item__primary-text,
.mdc-list-item__content {
display: flex !important;
align-items: flex-start !important;
gap: 10px !important;
width: 100% !important;
white-space: normal !important;
overflow: visible !important;
}
.ai-card-ic {
width: 32px !important;
height: 32px !important;
min-width: 32px !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
border-radius: 8px !important;
font-size: 13px !important;
flex-shrink: 0 !important;
background: var(--c-bg) !important;
color: var(--c-color) !important;
margin: 0 !important;
opacity: 1 !important;
transition:
background 0.16s ease,
color 0.16s ease,
transform 0.16s ease !important;
fa-icon {
font-size: 13px !important;
width: auto !important;
min-width: auto !important;
margin: 0 !important;
color: inherit !important;
opacity: 1 !important;
}
}
.ai-card-body {
display: flex !important;
flex-direction: column !important;
flex: 1 !important;
min-width: 0 !important;
text-align: left !important;
white-space: normal !important;
}
.ai-card-t {
font-size: 12.5px !important;
font-weight: 600 !important;
color: #0f172a !important;
line-height: 1.25 !important;
white-space: normal !important;
display: block !important;
}
.ai-card-d {
font-size: 10.5px !important;
color: #64748b !important;
margin-top: 3px !important;
line-height: 1.35 !important;
white-space: normal !important;
display: block !important;
}
&:hover {
background: var(--c-bg) !important;
.ai-card-ic {
background: var(--c-hover) !important;
color: #fff !important;
transform: scale(1.06) !important;
}
.ai-card-t {
color: var(--c-color) !important;
}
}
}
}
.ai-menu-header {
@@ -488,70 +585,101 @@ $icon-hover-border: rgb(255 255 255 / 28%);
font-size: 11px;
}
// Compact card per AI module
.ai-card.mat-mdc-menu-item {
display: flex !important;
align-items: flex-start;
gap: 10px;
padding: 9px 10px;
min-height: 0;
height: auto;
border-radius: 9px;
font-family: inherit;
line-height: 1.3;
white-space: normal;
transition: background 0.16s ease;
// ===== User Account Dropdown Menu =====
::ng-deep .t-user-menu.mat-mdc-menu-panel {
min-width: 190px !important;
border-radius: 10px !important;
box-shadow:
0 10px 25px rgb(15 23 42 / 12%),
0 2px 6px rgb(15 23 42 / 6%) !important;
border: 1px solid #e2e8f0 !important;
padding: 6px 0 !important;
background: #fff !important;
.ai-card-ic {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
font-size: 13px;
flex-shrink: 0;
background: var(--c-bg);
color: var(--c-color);
.mat-mdc-menu-content {
padding: 4px 0 !important;
}
.mat-mdc-menu-item {
height: 38px !important;
min-height: 38px !important;
line-height: 38px !important;
padding: 0 14px !important;
display: flex !important;
align-items: center !important;
font-size: 13px !important;
font-weight: 500 !important;
color: #334155 !important;
transition:
background 0.16s ease,
color 0.16s ease,
transform 0.16s ease;
}
background-color 0.15s ease,
color 0.15s ease !important;
.ai-card-body {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
text-align: left;
}
.ai-card-t {
font-size: 12.5px;
font-weight: 600;
color: #0f172a;
line-height: 1.25;
}
.ai-card-d {
font-size: 10.5px;
color: #64748b;
margin-top: 2px;
line-height: 1.35;
}
&:hover {
background: var(--c-bg);
.ai-card-ic {
background: var(--c-hover);
color: #fff;
transform: scale(1.06);
.mat-mdc-menu-item-text {
display: inline-flex !important;
align-items: center !important;
width: 100% !important;
font-size: 13px !important;
color: inherit !important;
font-weight: 500 !important;
}
.ai-card-t {
color: var(--c-color);
mat-icon {
margin-right: 10px !important;
width: 18px !important;
height: 18px !important;
min-width: 18px !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
color: #64748b !important;
font-size: 14px !important;
flex-shrink: 0 !important;
fa-icon {
font-size: 14px !important;
width: 14px !important;
min-width: 14px !important;
text-align: center !important;
margin-right: 0 !important;
color: inherit !important;
opacity: 1 !important;
}
}
span {
font-size: 13px !important;
color: inherit !important;
font-weight: 500 !important;
white-space: nowrap !important;
}
&:hover {
background-color: #f1f5f9 !important;
color: #0d47a1 !important;
mat-icon {
color: #0d47a1 !important;
}
}
&#logout {
color: #dc2626 !important;
border-top: 1px solid #f1f5f9 !important;
margin-top: 4px !important;
padding-top: 2px !important;
mat-icon {
color: #dc2626 !important;
}
&:hover {
background-color: #fef2f2 !important;
color: #b91c1c !important;
mat-icon {
color: #b91c1c !important;
}
}
}
}
}
@@ -49,7 +49,6 @@ import { MatIcon } from '@angular/material/icon';
import { NotificationsTrayComponent as NotificationsTrayComponent_1 } from '../../../shared/notifications-tray/notifications-tray.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DocumentationLinksService } from 'app/shared/services/documentation-links.service';
import { businessToday } from 'app/core/utils/business-today';
/**
* Toolbar component.
@@ -93,14 +92,6 @@ export class ToolbarComponent implements OnInit, AfterViewInit, AfterContentChec
@ViewChild('templateAppMenu') templateAppMenu: TemplateRef<any>;
@ViewChild('notificationsTray') notificationsTray: NotificationsTrayComponent;
/** The signed-in user's office — this chip read "HO" for every login, branch staff included. */
get officeLabel(): string {
if (this.authenticationService.isHeadOfficeUser()) {
return 'HO';
}
return this.authenticationService.getCredentials()?.officeName || '—';
}
/** Subscription to breakpoint observer for handset. */
isHandset$: Observable<boolean> = this.breakpointObserver
.observe(Breakpoints.Handset)
@@ -109,10 +100,8 @@ export class ToolbarComponent implements OnInit, AfterViewInit, AfterContentChec
/** Sets the initial state of sidenav as collapsed. Not collapsed if false. */
sidenavCollapsed = true;
/** The bank's "today" for the top bar — the business date when enabled, else the server date. */
get today(): Date {
return businessToday();
}
/** Current date for top bar display */
today = new Date();
/** Last login time display */
lastLoginTime = this.formatLastLogin();
-30
View File
@@ -1,30 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/**
* The bank's "today": the business date while `enable-business-date` is on, the server's own date
* while it is off. The server decides which (GET /branchday/today); the web app stores the answer
* in `mifosXServerDate` at login and whenever the business date changes. Before that has happened
* the browser clock is the only date there is, so it is the fallback.
*/
export function businessToday(): Date {
const stored = localStorage.getItem('mifosXServerDate');
const match = stored ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(stored) : null;
if (!match) {
return new Date();
}
// Built from parts so it is local midnight — new Date('yyyy-MM-dd') would be UTC midnight and
// land on the previous day in any timezone behind UTC.
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
}
/** {@link businessToday} as yyyy-MM-dd. */
export function businessTodayIso(): string {
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -0,0 +1,646 @@
<!--
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/.
-->
<div class="fd">
<div class="fd-head">
<div>
<h2 class="fd-head-t"><fa-icon icon="percentage"></fa-icon> FD Interest Posting Reports</h2>
<div class="fd-head-s">
Each branch gets its own report for each fixed deposit scheme, generated automatically at 12:00 noon on the
posting date. One user puts the figures up and a second approves them — whoever generated a report cannot
approve it. Approved interest posts when the branch closes its day, and it cannot close until then.
</div>
</div>
<div class="fd-head-a">
<label class="fd-date">
Posting date
<input type="date" [(ngModel)]="postingDate" (change)="load()" />
</label>
<button class="fd-btn" (click)="load()" [disabled]="loading"><fa-icon icon="undo"></fa-icon>Reload</button>
<button class="fd-btn fd-btn--primary" (click)="openPreview()" [disabled]="previewLoading">
<fa-icon icon="eye"></fa-icon>Preview interest (today)
</button>
<button class="fd-btn" (click)="openOutsideApproval()" [disabled]="outsideApprovalLoading">
<fa-icon icon="bolt"></fa-icon>Posted outside approval
</button>
@if (isHeadOffice) {
<button class="fd-btn" (click)="openProductSummary()" [disabled]="productSummaryLoading">
<fa-icon icon="table"></fa-icon>Product summary
</button>
}
</div>
</div>
@if (!approvalEnabled) {
<div class="fd-alert fd-alert--info">
<fa-icon icon="info-circle"></fa-icon>
<div>
Fixed deposit interest is currently posted directly by the nightly job, so there is nothing to approve here.
Turn on <strong>fd-interest-posting-approval</strong> under System &rarr; Configurations to put deposits behind
this report.
</div>
</div>
}
@if (isHeadOffice) {
<div class="fd-tabs">
<button class="fd-tab" [class.is-active]="tab === 'own'" (click)="switchTab('own')">
<fa-icon icon="building"></fa-icon>My branch{{ myBranch ? ' — ' + myBranch : '' }}
</button>
<button class="fd-tab" [class.is-active]="tab === 'others'" (click)="switchTab('others')">
<fa-icon icon="sitemap"></fa-icon>Other branches
</button>
</div>
@if (tab === 'others') {
<div class="fd-alert fd-alert--info">
<fa-icon icon="info-circle"></fa-icon>
<div>
Each branch generates and approves its own reports. The head office can act here only for a branch that has
not opened the day, and only once every other open branch has closed. Anything it approves is marked (HO).
</div>
</div>
}
}
@if (errorMessage) {
<div class="fd-alert fd-alert--error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>{{ errorMessage }}</div>
</div>
}
@if (notice) {
<div class="fd-alert fd-alert--ok">
<fa-icon icon="check"></fa-icon>
<div>{{ notice }}</div>
</div>
}
@if (pendingCount > 0) {
<div class="fd-alert fd-alert--warn">
<fa-icon icon="clock"></fa-icon>
<div>
<strong>{{ pendingCount }} report(s) waiting for approval.</strong> The day cannot be closed until they are
approved, and the user who generated a report cannot be the one to approve it.
</div>
</div>
}
@if (due.length > 0) {
<div class="fd-alert fd-alert--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div class="fd-due-body">
<div>Fixed deposit interest is due but no report has been generated yet for:</div>
@for (d of due; track d.productId + '-' + d.officeId) {
<div class="fd-due-row">
<strong>{{ d.productName }}</strong>
<span class="fd-chip" [ngClass]="fdTypeClass(d.fdType)">{{ fdTypeShort(d.fdType) }}</span>
<span>
&middot; {{ d.officeName }} &middot; {{ d.postingDate }} &middot; {{ d.accountCount }} deposit(s) &middot;
&#8377;{{ d.totalInterest | number: '1.2-2' }}
</span>
@if (d.canAct) {
<button class="fd-btn fd-btn--primary fd-btn--slim" (click)="generate(d)" [disabled]="busy">
<fa-icon icon="sync"></fa-icon>{{ tab === 'others' ? 'Generate on behalf' : 'Generate report' }}
</button>
} @else if (d.blockedReason) {
<span class="fd-blocked">{{ d.blockedReason }}</span>
}
</div>
}
</div>
</div>
}
<!-- Summary strip -->
<div class="fd-summary">
<div class="fd-stat">
<div class="fd-stat__label">Interest due, no report</div>
<div class="fd-stat__value">&#8377;{{ dueTotal | number: '1.2-2' }}</div>
<div class="fd-stat__sub">{{ dueAccountTotal }} deposit(s) &middot; {{ due.length }} product(s)</div>
</div>
<div class="fd-stat fd-stat--warn">
<div class="fd-stat__label">Awaiting approval</div>
<div class="fd-stat__value">&#8377;{{ awaitingTotal | number: '1.2-2' }}</div>
<div class="fd-stat__sub">{{ awaitingReports.length }} report(s) &middot; blocking day close</div>
</div>
<div class="fd-stat fd-stat--ok">
<div class="fd-stat__label">Approved &mdash; posts at close</div>
<div class="fd-stat__value">&#8377;{{ approvedTotal | number: '1.2-2' }}</div>
<div class="fd-stat__sub">{{ approvedReports.length }} report(s)</div>
</div>
<div class="fd-stat fd-stat--posted">
<div class="fd-stat__label">Posted today</div>
<div class="fd-stat__value">&#8377;{{ postedTotal | number: '1.2-2' }}</div>
<div class="fd-stat__sub">{{ postedReports.length }} report(s)</div>
</div>
</div>
@if (showPreview) {
<div class="fd-card">
<div class="fd-detail-head">
<div>
<h3>FD interest preview &mdash; earned up to {{ previewAsOf }}</h3>
<div class="muted">
What each deposit would be paid if interest were posted today. View only &mdash; nothing is saved, approved
or posted.
</div>
</div>
<div class="fd-total">
<span>Total so far</span>
<strong>&#8377;{{ previewTotal | number: '1.2-2' }}</strong>
<span class="muted">{{ previewRows.length }} deposit(s)</span>
</div>
</div>
<div
style="
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 14px 20px;
border-bottom: 1px solid var(--fd-border-soft);
"
>
<button class="fd-btn fd-btn--slim" (click)="openPreview()" [disabled]="previewLoading">
<fa-icon icon="undo"></fa-icon>Refresh
</button>
<button
class="fd-btn fd-btn--slim"
(click)="exportPreview()"
[disabled]="previewLoading || previewRows.length === 0"
>
<fa-icon icon="file-excel"></fa-icon>Export Excel
</button>
<button class="fd-btn fd-btn--slim" (click)="closePreview()"><fa-icon icon="times"></fa-icon>Close</button>
</div>
@if (previewLoading) {
<div class="fd-empty">Working out what each deposit has earned up to today&hellip;</div>
} @else {
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th>Branch</th>
<th>Product</th>
<th>Scheme</th>
<th class="num">Deposits</th>
<th class="num">Interest so far</th>
</tr>
</thead>
<tbody>
@for (g of previewGroups; track g.officeName + g.productName) {
<tr>
<td>{{ g.officeName }}</td>
<td>
<strong>{{ g.productName }}</strong>
</td>
<td>
<span class="fd-chip" [ngClass]="fdTypeClass(g.fdType)">{{ fdTypeShort(g.fdType) }}</span>
</td>
<td class="num">{{ g.accounts }}</td>
<td class="num">&#8377;{{ g.interest | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="fd-empty">No fixed deposit has interest waiting to be posted.</td>
</tr>
}
</tbody>
</table>
</div>
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th>Account no</th>
<th>Customer</th>
<th>Scheme</th>
<th>Interest for</th>
<th class="num">Deposit</th>
<th class="num">Rate %</th>
<th>Maturity</th>
<th>Pays into</th>
<th class="num">Interest so far</th>
</tr>
</thead>
<tbody>
@for (r of previewRows; track r.fdAccountId + '-' + r.interestPostingDate) {
<tr>
<td class="fd-mono">{{ r.accountNo }}</td>
<td>{{ r.clientName }}</td>
<td>
<span class="fd-chip" [ngClass]="fdTypeClass(r.fdType)">{{ fdTypeShort(r.fdType) }}</span>
</td>
<td class="fd-mono">{{ r.interestPeriodFrom || '—' }} &rarr; {{ r.interestPostingDate }}</td>
<td class="num">&#8377;{{ r.depositAmount | number: '1.2-2' }}</td>
<td class="num">{{ r.interestRate | number: '1.2-2' }}</td>
<td class="fd-mono">{{ r.maturityDate || '—' }}</td>
<td class="fd-sub">{{ r.payoutAccountNo || 'Stays in the deposit' }}</td>
<td class="num">&#8377;{{ r.interest | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="8"><strong>Total</strong></td>
<td class="num">
<strong>&#8377;{{ previewTotal | number: '1.2-2' }}</strong>
</td>
</tr>
</tfoot>
</table>
</div>
}
</div>
}
@if (showOutsideApproval) {
<div class="fd-card">
<div class="fd-detail-head">
<div>
<h3>Fixed deposit interest posted outside this report</h3>
<div class="muted">
A deposit that matured before its own posting cycle completed has its last period settled directly, the
moment it matures &mdash; before any report here could be generated or approved for it. Listed here so
nothing paid to a customer is invisible, approved or not.
</div>
</div>
<div class="fd-total">
<span>Total</span>
<strong>&#8377;{{ outsideApprovalTotal | number: '1.2-2' }}</strong>
<span class="muted">{{ outsideApprovalRows.length }} posting(s)</span>
</div>
</div>
<div
style="
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 10px;
padding: 14px 20px;
border-bottom: 1px solid var(--fd-border-soft);
"
>
<label class="fd-date">
From
<input type="date" [(ngModel)]="outsideApprovalFrom" (change)="loadOutsideApproval()" />
</label>
<label class="fd-date">
To
<input type="date" [(ngModel)]="outsideApprovalTo" (change)="loadOutsideApproval()" />
</label>
<button class="fd-btn fd-btn--slim" (click)="loadOutsideApproval()" [disabled]="outsideApprovalLoading">
<fa-icon icon="undo"></fa-icon>Refresh
</button>
<button class="fd-btn fd-btn--slim" (click)="closeOutsideApproval()">
<fa-icon icon="times"></fa-icon>Close
</button>
</div>
@if (outsideApprovalLoading) {
<div class="fd-empty">Looking for deposit interest posted outside this report&hellip;</div>
} @else {
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th>Product</th>
<th>Scheme</th>
<th class="num">Deposits</th>
<th class="num">Interest</th>
</tr>
</thead>
<tbody>
@for (g of outsideApprovalGroups; track g.productName) {
<tr>
<td>
<strong>{{ g.productName }}</strong>
</td>
<td>
<span class="fd-chip" [ngClass]="fdTypeClass(g.fdType)">{{ fdTypeShort(g.fdType) }}</span>
</td>
<td class="num">{{ g.accounts }}</td>
<td class="num">&#8377;{{ g.interest | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="4" class="fd-empty">
No fixed deposit interest was posted outside this report in this period.
</td>
</tr>
}
</tbody>
</table>
</div>
@if (outsideApprovalRows.length > 0) {
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th>Account no</th>
<th>Customer</th>
<th>Product</th>
<th>Scheme</th>
<th>Interest for</th>
<th class="num">Interest</th>
<th>Posted at</th>
</tr>
</thead>
<tbody>
@for (r of outsideApprovalRows; track r.transactionId) {
<tr>
<td class="fd-mono">{{ r.accountNo }}</td>
<td>{{ r.clientName }}</td>
<td>{{ r.productName }}</td>
<td>
<span class="fd-chip" [ngClass]="fdTypeClass(r.fdType)">{{ fdTypeShort(r.fdType) }}</span>
</td>
<td class="fd-mono">{{ r.interestFor }}</td>
<td class="num">&#8377;{{ r.interestAmount | number: '1.2-2' }}</td>
<td class="fd-sub">{{ r.postedAt | date: 'dd MMM yyyy, HH:mm' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="5"><strong>Total</strong></td>
<td class="num">
<strong>&#8377;{{ outsideApprovalTotal | number: '1.2-2' }}</strong>
</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
}
}
</div>
}
@if (showProductSummary) {
<div class="fd-card">
<div class="fd-detail-head">
<div>
<h3>Interest by product</h3>
<div class="muted">
Posted through this report, still awaiting approval or posting, and posted directly at maturity instead —
added up to what the product has paid out in total over the period.
</div>
</div>
<div class="fd-total">
<span>Total, all products</span>
<strong>&#8377;{{ productSummaryGrandTotal.total | number: '1.2-2' }}</strong>
</div>
</div>
<div
style="
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 10px;
padding: 14px 20px;
border-bottom: 1px solid var(--fd-border-soft);
"
>
<label class="fd-date">
From
<input type="date" [(ngModel)]="productSummaryFrom" (change)="loadProductSummary()" />
</label>
<label class="fd-date">
To
<input type="date" [(ngModel)]="productSummaryTo" (change)="loadProductSummary()" />
</label>
<button class="fd-btn fd-btn--slim" (click)="loadProductSummary()" [disabled]="productSummaryLoading">
<fa-icon icon="undo"></fa-icon>Refresh
</button>
<button class="fd-btn fd-btn--slim" (click)="closeProductSummary()">
<fa-icon icon="times"></fa-icon>Close
</button>
</div>
@if (productSummaryLoading) {
<div class="fd-empty">Adding up interest by product&hellip;</div>
} @else {
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th>Product</th>
<th>Scheme</th>
<th>Posting period</th>
<th class="num">Posted (report)</th>
<th class="num">Pending approval</th>
<th class="num">Posted at maturity</th>
<th class="num">Total</th>
</tr>
</thead>
<tbody>
@for (row of productSummaryRows; track row.productId) {
<tr>
<td>
<strong>{{ row.productName }}</strong>
</td>
<td>
<span class="fd-chip" [ngClass]="fdTypeClass(row.fdType)">{{ fdTypeShort(row.fdType) }}</span>
</td>
<td class="fd-sub">{{ row.postingPeriodLabel || '—' }}</td>
<td class="num">&#8377;{{ row.posted | number: '1.2-2' }}</td>
<td class="num">&#8377;{{ row.pending | number: '1.2-2' }}</td>
<td class="num">&#8377;{{ row.maturityPosted | number: '1.2-2' }}</td>
<td class="num">
<strong>&#8377;{{ row.total | number: '1.2-2' }}</strong>
</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="fd-empty">No fixed deposit interest in this period.</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3"><strong>Total</strong></td>
<td class="num">
<strong>&#8377;{{ productSummaryGrandTotal.posted | number: '1.2-2' }}</strong>
</td>
<td class="num">
<strong>&#8377;{{ productSummaryGrandTotal.pending | number: '1.2-2' }}</strong>
</td>
<td class="num">
<strong>&#8377;{{ productSummaryGrandTotal.maturityPosted | number: '1.2-2' }}</strong>
</td>
<td class="num">
<strong>&#8377;{{ productSummaryGrandTotal.total | number: '1.2-2' }}</strong>
</td>
</tr>
</tfoot>
</table>
</div>
}
</div>
}
<div class="fd-card">
<div class="fd-card-h">
<h2>Reports{{ postingDate ? ' for ' + postingDate : '' }}</h2>
<span class="fd-card-sub">One report per scheme, per branch</span>
</div>
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th>Branch</th>
<th>Scheme</th>
<th>Type</th>
<th>Posting date</th>
<th class="num">Deposits</th>
<th class="num">Total interest</th>
<th>Status</th>
<th>Maker</th>
<th>Checker</th>
<th></th>
</tr>
</thead>
<tbody>
@for (r of reports; track r.id) {
<tr class="is-clickable" [class.is-selected]="selected?.id === r.id" (click)="open(r)">
<td>{{ r.officeName }}</td>
<td>
<strong>{{ r.productName }}</strong>
</td>
<td [title]="fdTypeLabel(r.fdType)">
<span class="fd-chip" [ngClass]="fdTypeClass(r.fdType)">{{ fdTypeShort(r.fdType) }}</span>
</td>
<td class="fd-mono">{{ r.postingDate }}</td>
<td class="num">{{ r.accountCount }}</td>
<td class="num">&#8377;{{ r.totalInterest | number: '1.2-2' }}</td>
<td>
<span class="fd-badge" [ngClass]="statusClass(r.status)">{{ r.status }}</span>
@if (r.failureReason) {
<div class="fd-fail" [title]="r.failureReason">Posting failed: {{ r.failureReason }}</div>
}
</td>
<td class="fd-sub">
{{ r.generatedBy || 'System' }} &middot; {{ r.generationMode }}<br />{{
r.generatedAt | date: 'dd MMM, HH:mm'
}}
</td>
<td class="fd-sub">
@if (r.approvedAt) {
{{ r.approvedBy }}
@if (r.approvedOnBehalf) {
<span class="fd-behalf">(HO)</span>
}
<br />{{ r.approvedAt | date: 'dd MMM, HH:mm' }}
} @else {
&mdash;
}
</td>
<td (click)="$event.stopPropagation()">
<div style="display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end">
@if (canRegenerate(r)) {
<button class="fd-btn fd-btn--slim" (click)="regenerate(r)" [disabled]="busy">
<fa-icon icon="sync"></fa-icon>Regenerate
</button>
}
@if (canApprove(r)) {
<button class="fd-btn fd-btn--primary fd-btn--slim" (click)="approve(r)" [disabled]="busy">
<fa-icon icon="check"></fa-icon>{{ tab === 'others' ? 'Approve on behalf' : 'Approve' }}
</button>
} @else if (r.status === 'GENERATED' && r.approveBlockedReason) {
<span class="fd-blocked">{{ r.approveBlockedReason }}</span>
}
</div>
</td>
</tr>
} @empty {
<tr>
<td colspan="10" class="fd-empty">
@if (loading) {
Loading&hellip;
} @else {
No fixed deposit interest posting report for {{ postingDate || 'this date' }}. Reports are generated
at 12:00 noon on a product's posting date.
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
@if (selected) {
<div class="fd-card">
<div class="fd-detail-head">
<div>
<h3>
{{ selected.officeName }} &middot; {{ selected.productName }} &mdash; {{ selected.postingDate }}
<span class="fd-chip" [ngClass]="fdTypeClass(selected.fdType)">{{ fdTypeShort(selected.fdType) }}</span>
<span class="fd-badge fd-badge--muted">{{ selected.postingPeriodLabel }}</span>
<span class="fd-badge" [ngClass]="statusClass(selected.status)">{{ selected.status }}</span>
</h3>
<div class="muted">{{ fdTypeLabel(selected.fdType) }}</div>
<div class="muted">
Dr Interest Expense &rarr; Cr {{ selected.payableGlCode }} {{ selected.payableGlName }}; Dr
{{ selected.payableGlCode }} &rarr; Cr {{ selected.paidGlCode }} {{ selected.paidGlName }}; then Dr
{{ selected.paidGlCode }} &rarr; Cr each deposit below
</div>
</div>
<div class="fd-total">
<span>Total interest</span>
<strong>&#8377;{{ selected.totalInterest | number: '1.2-2' }}</strong>
<span class="muted">{{ selected.accountCount }} deposit(s)</span>
</div>
</div>
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th>Account no</th>
<th>Account holder</th>
<th>Interest for</th>
<th class="num">Deposit</th>
<th class="num">Rate %</th>
<th>Maturity</th>
<th class="num">Interest</th>
<th class="num">Balance before</th>
<th class="num">Balance after</th>
<th>Pays into</th>
<th>Posted txn</th>
</tr>
</thead>
<tbody>
@for (l of selected.lines; track l.fdAccountId + '-' + l.interestPostingDate) {
<tr>
<td class="fd-mono">{{ l.accountNo }}</td>
<td>{{ l.clientName }}</td>
<td class="fd-mono">{{ l.interestPeriodFrom || '—' }} &rarr; {{ l.interestPostingDate }}</td>
<td class="num">&#8377;{{ l.depositAmount | number: '1.2-2' }}</td>
<td class="num">{{ l.interestRate | number: '1.2-2' }}</td>
<td class="fd-mono">{{ l.maturityDate || '—' }}</td>
<td class="num">&#8377;{{ l.interestAmount | number: '1.2-2' }}</td>
<td class="num">&#8377;{{ l.balanceBefore | number: '1.2-2' }}</td>
<td class="num">&#8377;{{ l.balanceAfter | number: '1.2-2' }}</td>
<td class="fd-sub">{{ l.payoutAccountNo || 'Stays in the deposit' }}</td>
<td class="fd-sub">{{ l.fdTransactionId ? '#' + l.fdTransactionId : '—' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="6"><strong>Total</strong></td>
<td class="num">
<strong>&#8377;{{ selected.totalInterest | number: '1.2-2' }}</strong>
</td>
<td colspan="4"></td>
</tr>
</tfoot>
</table>
</div>
</div>
}
</div>
@@ -0,0 +1,559 @@
/**
* 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/.
*/
:host {
--fd-navy: #101723;
--fd-accent: #1b4d8f;
--fd-accent-dark: #123c70;
--fd-ground: #f7f8fa;
--fd-border: #dfe4eb;
--fd-border-soft: #edf0f4;
--fd-muted: #5b6676;
--fd-text: #3c4757;
--fd-amber: #6b4600;
--fd-amber-dark: #5a3b00;
--fd-amber-tint: #fdf3dc;
--fd-amber-border: #efd9a3;
--fd-amber-wash: #fffcf4;
--fd-green: #185e38;
--fd-green-dark: #1d6f42;
--fd-green-tint: #e2f3e8;
--fd-green-wash: #f3faf5;
--fd-blue-tint: #e6eef8;
--fd-slate-tint: #edf0f4;
display: block;
font-family: 'IBM Plex Sans', system-ui, sans-serif;
color: var(--fd-navy);
}
.fd {
max-width: 1320px;
margin: 0 auto;
padding: 20px 4px 56px;
}
// ── Header ──────────────────────────────────────────────────────────────
.fd-head {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 22px;
}
.fd-head-t {
margin: 0;
font-family: 'IBM Plex Serif', serif;
font-weight: 600;
font-size: 26px;
letter-spacing: -0.01em;
display: flex;
align-items: center;
gap: 10px;
fa-icon {
color: var(--fd-accent);
font-size: 20px;
}
}
.fd-head-s {
max-width: 720px;
margin-top: 8px;
font-size: 13.5px;
line-height: 1.6;
color: var(--fd-muted);
}
.fd-head-a {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 10px;
}
.fd-date {
display: flex;
flex-direction: column;
gap: 5px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--fd-muted);
input {
font-family: 'IBM Plex Mono', monospace;
font-size: 13px;
padding: 9px 11px;
min-height: 44px;
border: 1px solid #cdd5df;
border-radius: 7px;
background: #fff;
color: var(--fd-navy);
}
}
// ── Buttons ─────────────────────────────────────────────────────────────
.fd-btn {
font-family: inherit;
font-size: 12.5px;
font-weight: 500;
padding: 8px 14px;
min-height: 44px;
border-radius: 7px;
border: 1px solid #cdd5df;
background: #fff;
color: var(--fd-navy);
cursor: pointer;
white-space: nowrap;
fa-icon {
margin-right: 6px;
}
&:hover:not([disabled]) {
background: #f2f5f9;
}
&[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
&--primary {
background: var(--fd-accent);
border-color: var(--fd-accent);
color: #fff;
&:hover:not([disabled]) {
background: var(--fd-accent-dark);
}
}
&--slim {
min-height: 34px;
padding: 6px 11px;
font-size: 12px;
}
}
// ── Tabs ────────────────────────────────────────────────────────────────
.fd-tabs {
display: flex;
gap: 4px;
margin-bottom: 20px;
border-bottom: 1px solid var(--fd-border);
}
.fd-tab {
font-family: inherit;
font-size: 13px;
font-weight: 500;
padding: 10px 6px;
min-height: 44px;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--fd-muted);
cursor: pointer;
fa-icon {
margin-right: 7px;
}
&.is-active {
border-bottom-color: var(--fd-accent);
color: var(--fd-accent);
font-weight: 600;
}
& + .fd-tab {
margin-left: 18px;
}
}
// ── Alerts ──────────────────────────────────────────────────────────────
.fd-alert {
display: flex;
gap: 12px;
align-items: flex-start;
margin-bottom: 18px;
padding: 14px 18px;
border-radius: 10px;
font-size: 13px;
line-height: 1.6;
fa-icon {
margin-top: 2px;
flex-shrink: 0;
}
&--info {
background: #eef3fa;
border: 1px solid #c8d8ec;
color: #1f3a5c;
}
&--warn {
background: var(--fd-amber-tint);
border: 1px solid var(--fd-amber-border);
color: var(--fd-amber-dark);
strong {
color: var(--fd-amber-dark);
}
}
&--ok {
background: var(--fd-green-wash);
border: 1px solid #bfe3cc;
color: var(--fd-green);
}
&--error {
background: #fdecea;
border: 1px solid #f3b8b1;
color: #8a2b22;
}
}
.fd-due-body {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.fd-due-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 8px 0;
& + .fd-due-row {
border-top: 1px dashed var(--fd-amber-border);
}
}
.fd-blocked {
font-size: 11.5px;
color: var(--fd-muted);
}
// ── Summary strip ───────────────────────────────────────────────────────
.fd-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 20px;
@media (width <= 860px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.fd-stat {
background: #fff;
border: 1px solid var(--fd-border);
border-radius: 12px;
padding: 18px 20px;
&__label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--fd-muted);
}
&__value {
font-family: 'IBM Plex Mono', monospace;
font-variant-numeric: tabular-nums;
font-size: 25px;
font-weight: 500;
letter-spacing: -0.02em;
margin-top: 9px;
}
&__sub {
font-size: 11.5px;
color: var(--fd-muted);
margin-top: 4px;
}
&--warn {
border-color: var(--fd-amber-border);
.fd-stat__label,
.fd-stat__sub {
color: var(--fd-amber);
}
.fd-stat__value {
color: var(--fd-amber-dark);
}
}
&--ok {
.fd-stat__label {
color: var(--fd-green);
}
.fd-stat__value {
color: var(--fd-green);
}
}
&--posted {
.fd-stat__label {
color: var(--fd-accent);
}
.fd-stat__value {
color: var(--fd-accent);
}
}
}
// ── Cards ───────────────────────────────────────────────────────────────
.fd-card {
background: #fff;
border: 1px solid var(--fd-border);
border-radius: 12px;
margin-bottom: 20px;
overflow: hidden;
}
.fd-card-h {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: baseline;
padding: 16px 20px;
border-bottom: 1px solid var(--fd-border);
h2,
h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
}
.fd-card-sub {
font-size: 12px;
color: var(--fd-muted);
}
// ── Tables ──────────────────────────────────────────────────────────────
.fd-table-wrap {
overflow-x: auto;
}
.fd-table {
border-collapse: collapse;
width: 100%;
th {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--fd-muted);
text-align: left;
padding: 10px 14px;
border-bottom: 1px solid var(--fd-border);
white-space: nowrap;
}
td {
font-size: 13px;
padding: 12px 14px;
border-bottom: 1px solid var(--fd-border-soft);
vertical-align: top;
}
tbody tr:last-child td {
border-bottom: 0;
}
tbody tr.is-clickable {
cursor: pointer;
&:hover td {
background: #fafbfd;
}
}
tbody tr.is-selected td {
background: var(--fd-blue-tint);
}
tfoot tr {
background: var(--fd-ground);
}
.num {
text-align: right;
font-family: 'IBM Plex Mono', monospace;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
}
.fd-mono {
font-family: 'IBM Plex Mono', monospace;
font-variant-numeric: tabular-nums;
}
.fd-sub {
font-size: 11.5px;
color: var(--fd-muted);
margin-top: 3px;
}
.fd-empty {
text-align: center;
padding: 30px 16px;
color: var(--fd-muted);
font-size: 13px;
}
// ── Chips & badges ──────────────────────────────────────────────────────
.fd-chip {
display: inline-block;
padding: 2px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
white-space: nowrap;
&--single {
background: var(--fd-slate-tint);
color: var(--fd-text);
}
&--double {
background: var(--fd-blue-tint);
color: var(--fd-accent);
}
&--special {
background: var(--fd-amber-tint);
color: var(--fd-amber);
}
}
.fd-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
white-space: nowrap;
&--pending {
background: var(--fd-amber-tint);
color: var(--fd-amber);
}
&--approved {
background: var(--fd-green-tint);
color: var(--fd-green);
}
&--posted {
background: var(--fd-blue-tint);
color: var(--fd-accent);
}
&--muted {
background: var(--fd-slate-tint);
color: var(--fd-muted);
}
}
.fd-behalf {
font-size: 10.5px;
font-weight: 700;
color: var(--fd-accent);
margin-left: 4px;
}
.fd-fail {
margin-top: 4px;
font-size: 11px;
color: #a32b22;
}
// ── Detail panel ────────────────────────────────────────────────────────
.fd-detail-head {
display: flex;
flex-wrap: wrap;
gap: 20px;
justify-content: space-between;
align-items: flex-start;
padding: 22px 20px;
border-bottom: 1px solid var(--fd-border-soft);
background: #fbfcfd;
h3 {
margin: 0;
font-family: 'IBM Plex Serif', serif;
font-weight: 600;
font-size: 19px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 9px;
}
}
.fd-total {
text-align: right;
span:first-child {
display: block;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--fd-muted);
}
strong {
display: block;
font-family: 'IBM Plex Mono', monospace;
font-variant-numeric: tabular-nums;
font-size: 26px;
font-weight: 500;
margin-top: 6px;
letter-spacing: -0.02em;
}
}
.muted {
color: var(--fd-muted);
font-size: 12.5px;
margin-top: 4px;
}
@@ -0,0 +1,686 @@
/**
* 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 { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { SettingsService } from 'app/settings/settings.service';
import { exportWorkbook } from '../cashier/cashier-export';
import {
FdInterestDue,
FdInterestPreviewRow,
FdInterestReport,
FdInterestReportDetail,
FdInterestPostingReportService,
FdInterestSummaryRow,
FdOutsideApprovalRow,
FdReportScope,
FdType
} from './fd-interest-posting-report.service';
/**
* Fixed deposit interest posting reports.
*
* On a product's posting date the system generates, at noon, the list of what each deposit will be
* paid. One user of the branch reviews it and a second approves it — the maker cannot be the checker,
* because posting interest moves money to customers and, for a special deposit, straight out to their
* savings account. The approved amounts are posted when the branch closes the day, and until then it
* cannot close. Regenerating replaces the report and needs approving again.
*/
@Component({
selector: 'mifosx-fd-interest-posting-report',
templateUrl: './fd-interest-posting-report.component.html',
styleUrls: ['./fd-interest-posting-report.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent
]
})
export class FdInterestPostingReportComponent implements OnInit {
private reportService = inject(FdInterestPostingReportService);
private settingsService = inject(SettingsService);
postingDate = '';
loading = false;
busy = false;
errorMessage = '';
notice = '';
reports: FdInterestReport[] = [];
selected: FdInterestReportDetail | null = null;
/** Products with interest due and no report yet — the noon job ran before they had anything due, or failed. */
due: FdInterestDue[] = [];
/** 'own' — the user's own branch; 'others' — every other branch, a head-office-only tab. */
tab: FdReportScope = 'own';
isHeadOffice = false;
myBranch = '';
/** False where the bank has deposits on the old direct-posting path; every list here is then empty by design. */
approvalEnabled = true;
ngOnInit(): void {
this.postingDate = this.businessDateIso();
this.reportService.context().subscribe({
next: (ctx) => {
this.isHeadOffice = !!ctx?.headOffice;
this.myBranch = ctx?.officeName ?? '';
this.approvalEnabled = ctx?.approvalEnabled !== false;
},
error: () => (this.isHeadOffice = false)
});
this.load();
}
switchTab(tab: FdReportScope): void {
if (this.tab === tab) {
return;
}
this.tab = tab;
this.selected = null;
this.notice = '';
this.load();
}
/** Acting for another branch is the head office's exception, so it is spelt out before it happens. */
private confirmOnBehalf(officeName: string, action: string): boolean {
if (this.tab !== 'others') {
return true;
}
return confirm(
`${officeName} has not opened today. You are about to ${action} its fixed deposit interest posting report on its ` +
'behalf, from the head office. Continue?'
);
}
load(): void {
this.loading = true;
this.errorMessage = '';
this.reportService.list(this.postingDate || undefined, undefined, undefined, this.tab).subscribe({
next: (reports) => {
this.reports = reports ?? [];
this.loading = false;
const stillThere = this.selected && this.reports.find((r) => r.id === this.selected?.id);
if (stillThere) {
this.open(stillThere);
} else {
this.selected = null;
}
},
error: (err) => {
this.loading = false;
this.errorMessage = this.messageOf(err, 'Could not load the fixed deposit interest posting reports.');
}
});
this.reportService.dueWithoutReport(this.postingDate || undefined, this.tab).subscribe({
next: (due) => (this.due = due ?? []),
error: () => (this.due = [])
});
}
generate(item: FdInterestDue): void {
if (!this.confirmOnBehalf(item.officeName, 'generate')) {
return;
}
this.busy = true;
this.notice = '';
this.errorMessage = '';
this.reportService.regenerate(item.productId, item.officeId, item.postingDate).subscribe({
next: (res) => {
this.busy = false;
this.notice = `Report generated for ${item.productName} — ${item.officeName}. Another user of the branch has to approve it.`;
this.load();
this.reportService.detail(res.reportId).subscribe((detail) => (this.selected = detail));
},
error: (err) => {
this.busy = false;
this.errorMessage = this.messageOf(err, 'Could not generate the report.');
}
});
}
open(report: FdInterestReport): void {
this.errorMessage = '';
this.reportService.detail(report.id).subscribe({
next: (detail) => (this.selected = detail),
error: (err) => (this.errorMessage = this.messageOf(err, 'Could not load the report.'))
});
}
regenerate(report: FdInterestReport): void {
if (
!confirm(
`Regenerate the fixed deposit interest posting report for ${report.productName} — ${report.officeName}? ` +
'It will need approving again.'
) ||
!this.confirmOnBehalf(report.officeName, 'regenerate')
) {
return;
}
this.busy = true;
this.notice = '';
this.errorMessage = '';
this.reportService.regenerate(report.productId, report.officeId, report.postingDate).subscribe({
next: (res) => {
this.busy = false;
this.notice = `Report regenerated for ${report.productName}. Another user of the branch has to approve it.`;
this.selected = null;
this.load();
this.reportService.detail(res.reportId).subscribe((detail) => (this.selected = detail));
},
error: (err) => {
this.busy = false;
this.errorMessage = this.messageOf(err, 'Could not regenerate the report.');
}
});
}
approve(report: FdInterestReport): void {
const payout =
report.fdType === 'SPECIAL'
? ' This is a monthly-income product, so the interest is paid out to the customers’ linked savings accounts.'
: '';
if (
!confirm(
`Approve interest of ₹${report.totalInterest} across ${report.accountCount} deposit(s) for ${report.productName}?` +
`${payout} It will be posted when the branch closes the day.`
) ||
!this.confirmOnBehalf(report.officeName, 'approve')
) {
return;
}
this.busy = true;
this.notice = '';
this.errorMessage = '';
this.reportService.approve(report.id).subscribe({
next: () => {
this.busy = false;
this.notice = `Approved. Interest for ${report.productName} — ${report.officeName} will be posted when the branch closes the day.`;
this.load();
},
error: (err) => {
this.busy = false;
this.errorMessage = this.messageOf(err, 'Could not approve the report.');
}
});
}
canRegenerate(report: FdInterestReport): boolean {
return report.canAct && (report.status === 'GENERATED' || report.status === 'APPROVED');
}
/**
* The server decides this, not the screen: it alone knows who generated the report, and the maker
* cannot be the checker.
*/
canApprove(report: FdInterestReport): boolean {
return !!report.canApprove;
}
statusClass(status: string): string {
return (
{
GENERATED: 'fd-badge--pending',
APPROVED: 'fd-badge--approved',
POSTED: 'fd-badge--posted',
SUPERSEDED: 'fd-badge--muted'
}[status] ?? 'fd-badge--muted'
);
}
/** What the scheme means for the customer, spelt out rather than left as a code. */
fdTypeLabel(fdType: FdType | string): string {
return (
{
SINGLE: 'Single — interest paid at maturity',
DOUBLE: 'Double — interest compounded into the deposit',
SPECIAL: 'Special — interest paid monthly to linked savings'
}[fdType] ?? String(fdType ?? '')
);
}
fdTypeShort(fdType: FdType | string): string {
return (
{
SINGLE: 'Single',
DOUBLE: 'Double',
SPECIAL: 'Special'
}[fdType] ?? String(fdType ?? '')
);
}
fdTypeClass(fdType: FdType | string): string {
return (
{
SINGLE: 'fd-chip--single',
DOUBLE: 'fd-chip--double',
SPECIAL: 'fd-chip--special'
}[fdType] ?? 'fd-chip--single'
);
}
/** Pending reports that block this user's own day close; another branch's pending reports block only that branch. */
get pendingCount(): number {
return this.tab === 'own' ? this.reports.filter((r) => r.status === 'GENERATED').length : 0;
}
// ── Summary strip: the day's reports, at a glance ──
get dueTotal(): number {
return this.due.reduce((sum, d) => sum + Number(d.totalInterest || 0), 0);
}
get dueAccountTotal(): number {
return this.due.reduce((sum, d) => sum + Number(d.accountCount || 0), 0);
}
private reportsWithStatus(status: string): FdInterestReport[] {
return this.reports.filter((r) => r.status === status);
}
private totalOf(list: FdInterestReport[]): number {
return list.reduce((sum, r) => sum + Number(r.totalInterest || 0), 0);
}
get awaitingReports(): FdInterestReport[] {
return this.reportsWithStatus('GENERATED');
}
get approvedReports(): FdInterestReport[] {
return this.reportsWithStatus('APPROVED');
}
get postedReports(): FdInterestReport[] {
return this.reportsWithStatus('POSTED');
}
get awaitingTotal(): number {
return this.totalOf(this.awaitingReports);
}
get approvedTotal(): number {
return this.totalOf(this.approvedReports);
}
get postedTotal(): number {
return this.totalOf(this.postedReports);
}
// ── Preview: what each deposit has earned so far, as if posted today. View only. ──
showPreview = false;
previewLoading = false;
previewRows: FdInterestPreviewRow[] = [];
previewAsOf = '';
openPreview(): void {
this.showPreview = true;
this.previewLoading = true;
this.errorMessage = '';
this.previewAsOf = this.businessDateIso();
this.reportService.preview().subscribe({
next: (rows) => {
this.previewRows = rows ?? [];
this.previewLoading = false;
},
error: (err) => {
this.previewLoading = false;
this.previewRows = [];
this.errorMessage = this.messageOf(err, 'Could not load the fixed deposit interest preview.');
}
});
}
closePreview(): void {
this.showPreview = false;
}
get previewTotal(): number {
return this.previewRows.reduce((sum, r) => sum + Number(r.interest || 0), 0);
}
/** Totals by branch, product and scheme, for the summary above the deposit list and the export. */
get previewGroups(): {
officeName: string;
productName: string;
fdType: string;
accounts: number;
interest: number;
}[] {
const map = new Map<
string,
{ officeName: string; productName: string; fdType: string; accounts: number; interest: number }
>();
this.previewRows.forEach((r) => {
const key = `${r.officeName}|${r.productName}`;
const g = map.get(key) ?? {
officeName: r.officeName,
productName: r.productName,
fdType: r.fdType,
accounts: 0,
interest: 0
};
g.accounts += 1;
g.interest += Number(r.interest || 0);
map.set(key, g);
});
return [...map.values()];
}
exportPreview(): void {
const stamp = `Fixed deposit interest earned to ${this.previewAsOf} — preview, not posted`;
exportWorkbook(
[
{
sheetName: 'By deposit',
title: 'Fixed Deposit Interest Preview',
subtitle: stamp,
headers: [
'Branch',
'Product',
'Scheme',
'Account No',
'Customer',
'Interest For',
'Deposit',
'Rate %',
'Maturity',
'Balance',
'Pays Into',
'Interest So Far'
],
widths: [
18,
22,
10,
20,
26,
12,
16,
8,
12,
16,
18,
16
],
rows: [
...this.previewRows.map((r) => [
r.officeName,
r.productName,
this.fdTypeShort(r.fdType),
r.accountNo,
r.clientName,
r.interestPostingDate ?? '',
Number(r.depositAmount || 0),
Number(r.interestRate || 0),
r.maturityDate ?? '',
Number(r.balance || 0),
r.payoutAccountNo ?? '',
Number(r.interest || 0)
]),
[
'TOTAL',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
this.previewTotal
]
]
},
{
sheetName: 'By branch & product',
title: 'Fixed Deposit Interest Preview — Totals',
subtitle: stamp,
headers: [
'Branch',
'Product',
'Scheme',
'Deposits',
'Interest So Far'
],
widths: [
18,
22,
10,
10,
16
],
rows: [
...this.previewGroups.map((g) => [
g.officeName,
g.productName,
this.fdTypeShort(g.fdType),
g.accounts,
g.interest
]),
[
'TOTAL',
'',
'',
this.previewRows.length,
this.previewTotal
]
]
}
],
`fd-interest-preview-${this.previewAsOf}`
);
}
// ── Outside approval: deposit interest posted without this report, chiefly at maturity ──
showOutsideApproval = false;
outsideApprovalLoading = false;
outsideApprovalRows: FdOutsideApprovalRow[] = [];
outsideApprovalFrom = '';
outsideApprovalTo = '';
openOutsideApproval(): void {
this.showOutsideApproval = true;
if (!this.outsideApprovalTo) {
this.outsideApprovalTo = this.businessDateIso();
this.outsideApprovalFrom = `${this.outsideApprovalTo.slice(0, 7)}-01`;
}
this.loadOutsideApproval();
}
loadOutsideApproval(): void {
this.outsideApprovalLoading = true;
this.errorMessage = '';
this.reportService
.outsideApproval(this.outsideApprovalFrom || undefined, this.outsideApprovalTo || undefined, undefined, this.tab)
.subscribe({
next: (rows) => {
this.outsideApprovalRows = rows ?? [];
this.outsideApprovalLoading = false;
},
error: (err) => {
this.outsideApprovalLoading = false;
this.outsideApprovalRows = [];
this.errorMessage = this.messageOf(err, 'Could not load deposit interest posted outside this report.');
}
});
}
closeOutsideApproval(): void {
this.showOutsideApproval = false;
}
get outsideApprovalTotal(): number {
return this.outsideApprovalRows.reduce((sum, r) => sum + Number(r.interestAmount || 0), 0);
}
/** Totals by product and scheme, so a branch can see at a glance where its unapproved interest sits. */
get outsideApprovalGroups(): { productName: string; fdType: string; accounts: number; interest: number }[] {
const map = new Map<string, { productName: string; fdType: string; accounts: number; interest: number }>();
this.outsideApprovalRows.forEach((r) => {
const g = map.get(r.productName) ?? { productName: r.productName, fdType: r.fdType, accounts: 0, interest: 0 };
g.accounts += 1;
g.interest += Number(r.interestAmount || 0);
map.set(r.productName, g);
});
return [...map.values()];
}
// ── Product summary: due for the product vs posted through this report vs posted at maturity ──
// Head office only, same as the branch-wise summary it reuses — a branch reads its own due and
// posted totals off the strip and tables above, which already cover it.
showProductSummary = false;
productSummaryLoading = false;
productSummaryFrom = '';
productSummaryTo = '';
private summaryRows: FdInterestSummaryRow[] = [];
private productOutsideRows: FdOutsideApprovalRow[] = [];
openProductSummary(): void {
this.showProductSummary = true;
if (!this.productSummaryTo) {
this.productSummaryTo = this.businessDateIso();
this.productSummaryFrom = `${this.productSummaryTo.slice(0, 7)}-01`;
}
this.loadProductSummary();
}
loadProductSummary(): void {
this.productSummaryLoading = true;
this.errorMessage = '';
let pending = 2;
const done = () => {
pending -= 1;
if (pending === 0) this.productSummaryLoading = false;
};
this.reportService.summary(this.productSummaryFrom || undefined, this.productSummaryTo || undefined).subscribe({
next: (rows) => {
this.summaryRows = rows ?? [];
done();
},
error: (err) => {
this.summaryRows = [];
this.errorMessage = this.messageOf(err, 'Could not load the product summary.');
done();
}
});
this.reportService
.outsideApproval(this.productSummaryFrom || undefined, this.productSummaryTo || undefined)
.subscribe({
next: (rows) => {
this.productOutsideRows = rows ?? [];
done();
},
error: () => {
this.productOutsideRows = [];
done();
}
});
}
closeProductSummary(): void {
this.showProductSummary = false;
}
/**
* One row per product and scheme: what posted through this report (and what is still pending
* approval or posting), plus what posted outside it — chiefly a deposit's last interest, paid the
* moment it matured rather than through a periodic report.
*/
get productSummaryRows(): {
productId: number;
productName: string;
fdType: string;
postingPeriodLabel: string;
posted: number;
pending: number;
maturityPosted: number;
total: number;
}[] {
const map = new Map<
number,
{
productId: number;
productName: string;
fdType: string;
postingPeriodLabel: string;
posted: number;
pending: number;
maturityPosted: number;
total: number;
}
>();
const get = (productId: number, productName: string, fdType: string, label: string) => {
const existing = map.get(productId);
if (existing) return existing;
const created = {
productId,
productName,
fdType,
postingPeriodLabel: label,
posted: 0,
pending: 0,
maturityPosted: 0,
total: 0
};
map.set(productId, created);
return created;
};
this.summaryRows.forEach((r) => {
const row = get(r.productId, r.productName, r.fdType, r.postingPeriodLabel);
const amount = Number(r.totalInterest || 0);
if (r.status === 'POSTED') {
row.posted += amount;
} else if (r.status === 'GENERATED' || r.status === 'APPROVED') {
row.pending += amount;
}
});
this.productOutsideRows.forEach((r) => {
const row = get(r.productId, r.productName, r.fdType, '');
row.maturityPosted += Number(r.interestAmount || 0);
});
map.forEach((row) => {
row.total = row.posted + row.pending + row.maturityPosted;
});
return [...map.values()].sort((a, b) => a.productName.localeCompare(b.productName));
}
get productSummaryGrandTotal(): { posted: number; pending: number; maturityPosted: number; total: number } {
return this.productSummaryRows.reduce(
(sum, r) => ({
posted: sum.posted + r.posted,
pending: sum.pending + r.pending,
maturityPosted: sum.maturityPosted + r.maturityPosted,
total: sum.total + r.total
}),
{ posted: 0, pending: 0, maturityPosted: 0, total: 0 }
);
}
private businessDateIso(): string {
const businessDate = this.settingsService.businessDate as any;
const parsed = businessDate ? new Date(businessDate) : new Date();
if (isNaN(parsed.getTime())) {
return new Date().toISOString().slice(0, 10);
}
const month = String(parsed.getMonth() + 1).padStart(2, '0');
const day = String(parsed.getDate()).padStart(2, '0');
return `${parsed.getFullYear()}-${month}-${day}`;
}
private messageOf(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? err?.error?.message ?? fallback
);
}
}
@@ -0,0 +1,280 @@
/**
* 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, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
export type FdInterestReportStatus = 'GENERATED' | 'APPROVED' | 'POSTED' | 'SUPERSEDED';
/** Which branches a list covers: the user's own, or (head office only) every other branch. */
export type FdReportScope = 'own' | 'others';
/** The deposit scheme, which decides where a posting of interest ends up. */
export type FdType = 'SINGLE' | 'DOUBLE' | 'SPECIAL';
export interface FdInterestReport {
id: number;
productId: number;
productName: string;
fdType: FdType;
officeId: number;
officeName: string;
postingDate: string;
status: FdInterestReportStatus;
totalInterest: number;
accountCount: number;
generationMode: 'AUTO' | 'MANUAL';
generatedAt: string;
generatedBy: string | null;
approvedAt: string | null;
approvedBy: string | null;
approvedOnBehalf: boolean;
postedAt: string | null;
failureReason: string | null;
/** The product's posting cycle in words — Monthly, Quarterly, Half-yearly, Yearly. */
postingPeriodLabel: string;
/** Whether this user may regenerate it: their own branch, or the head office for a branch that did not open. */
canAct: boolean;
blockedReason: string | null;
/** Whether this user may approve it — false for the user who generated it, who cannot be its checker. */
canApprove: boolean;
approveBlockedReason: string | null;
}
export interface FdInterestReportLine {
fdAccountId: number;
accountNo: string;
clientName: string;
/**
* The day after the deposit's last paid period ended, or its activation date for a first-ever
* posting — null for a line posted before this was tracked. Paired with interestPostingDate, it is
* the span the interest was earned over.
*/
interestPeriodFrom: string | null;
/**
* The period this interest is for, as the interest engine dated it — not the day the branch closes.
* A deposit owed two unpaid periods appears on two lines, one per date.
*/
interestPostingDate: string;
interestAmount: number;
balanceBefore: number;
balanceAfter: number;
depositAmount: number | null;
interestRate: number | null;
maturityDate: string | null;
/** SPECIAL only: the linked savings account this interest is swept to after posting. */
payoutSavingsAccountId: number | null;
payoutAccountNo: string | null;
payoutTransactionId: number | null;
fdTransactionId: number | null;
}
export interface FdInterestReportDetail extends FdInterestReport {
payableGlCode: string | null;
payableGlName: string | null;
paidGlCode: string | null;
paidGlName: string | null;
lines: FdInterestReportLine[];
}
export interface FdInterestDue {
productId: number;
productName: string;
fdType: FdType;
officeId: number;
officeName: string;
postingDate: string;
accountCount: number;
totalInterest: number;
canAct: boolean;
blockedReason: string | null;
}
export interface FdInterestSummaryRow {
id: number;
postingDate: string;
officeId: number;
officeName: string;
productId: number;
productName: string;
fdType: FdType;
accountCount: number;
totalInterest: number;
status: FdInterestReportStatus;
approvedBy: string | null;
approvedOnBehalf: boolean;
approvedAt: string | null;
postedAt: string | null;
postingPeriodLabel: string;
}
/**
* One fixed deposit interest transaction that reached the customer's account without this report's
* approval — chiefly a deposit that matured before its own posting cycle completed, whose last period
* is settled directly by the maturity job. View only, for visibility.
*/
export interface FdOutsideApprovalRow {
transactionId: number;
fdAccountId: number;
accountNo: string;
clientName: string;
officeId: number;
officeName: string;
productId: number;
productName: string;
fdType: FdType;
interestFor: string;
interestAmount: number;
/** When the posting actually reached the account — not the period it is for. */
postedAt: string;
}
export interface FdInterestPreviewRow {
fdAccountId: number;
accountNo: string;
clientName: string;
officeId: number;
officeName: string;
productId: number;
productName: string;
fdType: FdType;
/** The day after the deposit's last paid period ended, or its activation date for the first one. */
interestPeriodFrom: string | null;
/** The period this interest belongs to, as the engine dated it. */
interestPostingDate: string;
depositAmount: number | null;
interestRate: number | null;
maturityDate: string | null;
balance: number;
interest: number;
payoutAccountNo: string | null;
}
/** One product's deposits due on the day being closed, as the day-close screen shows them. */
export interface FdDayCloseDueRow {
productId: number;
productName: string;
fdType: FdType;
accountCount: number;
totalInterest: number;
reportStatus: FdInterestReportStatus | 'NONE';
}
export interface FdDayCloseCheck {
businessDate: string;
canClose: boolean;
pendingProducts: string[];
due: FdDayCloseDueRow[];
}
/**
* The fixed deposit interest posting reports, one per branch and product: generated at noon on a
* posting date, approved by a second user of the branch, posted when the branch closes the day.
*/
@Injectable({ providedIn: 'root' })
export class FdInterestPostingReportService {
private http = inject(HttpClient);
private readonly base = '/fixeddepositinterestpostingreports';
/** scope 'own' is the user's own branch; 'others' (head office only) every other branch. */
list(
postingDate?: string,
status?: string,
officeId?: number,
scope?: FdReportScope
): Observable<FdInterestReport[]> {
let params = new HttpParams();
if (scope) params = params.set('scope', scope);
if (postingDate) params = params.set('postingDate', postingDate);
if (status) params = params.set('status', status);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<FdInterestReport[]>(this.base, { params });
}
/** Branch and product pairs with deposit interest due by the date and no report yet. */
dueWithoutReport(postingDate?: string, scope?: FdReportScope): Observable<FdInterestDue[]> {
let params = new HttpParams();
if (scope) params = params.set('scope', scope);
if (postingDate) params = params.set('postingDate', postingDate);
return this.http.get<FdInterestDue[]>(`${this.base}/due-without-report`, { params });
}
/**
* The signed-in user's branch, whether it is the head office, and whether deposits are on the
* approval path at all — with that switched off these screens are empty by design.
*/
context(): Observable<{ officeId: number; officeName: string; headOffice: boolean; approvalEnabled: boolean }> {
return this.http.get<{ officeId: number; officeName: string; headOffice: boolean; approvalEnabled: boolean }>(
`${this.base}/context`
);
}
detail(reportId: number): Observable<FdInterestReportDetail> {
return this.http.get<FdInterestReportDetail>(`${this.base}/${reportId}`);
}
regenerate(
productId: number,
officeId: number,
postingDate: string
): Observable<{ reportId: number; status: string }> {
return this.http.post<{ reportId: number; status: string }>(
this.base,
{ productId, officeId, postingDate },
{ params: new HttpParams().set('command', 'regenerate') }
);
}
approve(reportId: number): Observable<{ reportId: number; status: string }> {
return this.http.post<{ reportId: number; status: string }>(
`${this.base}/${reportId}`,
{},
{ params: new HttpParams().set('command', 'approve') }
);
}
/** What each deposit has earned and not been paid, as if posted today. View only. */
preview(officeId?: number): Observable<FdInterestPreviewRow[]> {
let params = new HttpParams();
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<FdInterestPreviewRow[]>(`${this.base}/preview`, { params });
}
/** What stops a branch closing its day, and the deposits behind it. */
dayCloseCheck(date?: string, officeId?: number): Observable<FdDayCloseCheck> {
let params = new HttpParams();
if (date) params = params.set('date', date);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<FdDayCloseCheck>(`${this.base}/day-close-check`, { params });
}
/** The head office's branch-wise view over a date range. */
summary(fromDate?: string, toDate?: string, productId?: number): Observable<FdInterestSummaryRow[]> {
let params = new HttpParams();
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
if (productId) params = params.set('productId', String(productId));
return this.http.get<FdInterestSummaryRow[]>(`${this.base}/summary`, { params });
}
/** Deposit interest posted without this report's approval — a deposit maturing mid-cycle, chiefly. */
outsideApproval(
fromDate?: string,
toDate?: string,
officeId?: number,
scope?: FdReportScope
): Observable<FdOutsideApprovalRow[]> {
let params = new HttpParams();
if (scope) params = params.set('scope', scope);
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<FdOutsideApprovalRow[]>(`${this.base}/outside-approval`, { params });
}
}
@@ -18,16 +18,9 @@
<p class="dialog-sub">NDTL &middot; CRR/SLR Computation Base &middot; As per RBI Master Circular</p>
</div>
<div class="header-right">
<!-- The close being reported, and under it the close the ratios are charged on. Two dates,
because they are not the same day and the requirement belongs to the earlier one. -->
<div class="as-of-box">
<strong>{{ reportingDateStr || '—' }}</strong>
<span>
Fortnight close
@if (baseDateStr) {
&middot; charged on {{ baseDateStr }}
}
</span>
<strong>{{ reportingDateStr }}</strong>
<span>Reporting Fortnight (45-day lag)</span>
</div>
<a
class="rbi-circular-btn"
@@ -49,44 +42,31 @@
<div class="summary-row">
<div class="summary-card sc-demand">
<div class="sc-label">Total Demand Liabilities</div>
<div class="sc-value">{{ crore(totalDemand) }}<span class="cr" *ngIf="totalDemand !== null">Cr</span></div>
<div class="sc-sub">Payable on demand</div>
<div class="sc-value">&#8377;12,843<span class="cr">Cr</span></div>
<div class="sc-change up">&#8593; 3.2% vs prev fortnight</div>
</div>
<div class="summary-card sc-time">
<div class="sc-label">Total Time Liabilities</div>
<div class="sc-value">{{ crore(totalTime) }}<span class="cr" *ngIf="totalTime !== null">Cr</span></div>
<div class="sc-sub">Payable on maturity</div>
<div class="sc-value">&#8377;64,618<span class="cr">Cr</span></div>
<div class="sc-change up">&#8593; 1.8% vs prev fortnight</div>
</div>
<div class="summary-card sc-asset">
<div class="sc-label">Assets with Banking System</div>
<div class="sc-value">
{{ crore(totalBankingAssets) }}<span class="cr" *ngIf="totalBankingAssets !== null">Cr</span>
</div>
<div class="sc-value">&#8377;8,240<span class="cr">Cr</span></div>
<div class="sc-change dn">&#8595; Deducted from NDTL</div>
</div>
<div class="summary-card sc-ndtl">
<div class="sc-label">Net Demand &amp; Time Liabilities (NDTL)</div>
<div class="sc-value">{{ crore(ndtl) }}<span class="cr" *ngIf="ndtl !== null">Cr</span></div>
<div class="sc-value">&#8377;69,221<span class="cr">Cr</span></div>
<div class="sc-sub">After deducting banking assets</div>
</div>
<div class="summary-card sc-crr">
<!-- The rate is named from the configuration, not fixed here: a card headed "CRR Required
(3%)" while Admin holds 4% states a rate the bank is not on. -->
<div class="sc-label">CRR Required{{ crrRate === null ? '' : ' (' + crrRate + '%)' }}</div>
<div class="sc-value">{{ crore(crrRequired) }}<span class="cr" *ngIf="crrRequired !== null">Cr</span></div>
<div class="sc-sub">
SLR Req: {{ crore(slrRequired) }}{{ slrRate === null ? '' : ' Cr (' + slrRate + '%)' }}
</div>
<div class="sc-label">CRR Required ({{ crrRate }}%)</div>
<div class="sc-value">&#8377;{{ crrRequired | number: '1.0-0' }}<span class="cr">Cr</span></div>
<div class="sc-sub">SLR Req: &#8377;{{ slrRequired | number: '1.0-0' }} Cr ({{ slrRate }}%)</div>
</div>
</div>
<!-- Why the figures are not there, in the return's own words. A dashboard that simply shows
dashes sends an administrator looking for a fault in the dashboard. -->
<div class="unavailable-note" *ngIf="unavailable">
<fa-icon icon="exclamation-triangle"></fa-icon>
{{ unavailable }}
</div>
<!-- Tab Navigation -->
<div class="tab-nav">
<button class="tab-btn" [class.active]="activeTab === 'demand'" (click)="activeTab = 'demand'">
@@ -144,21 +124,13 @@
</td>
</tr>
}
@empty {
<tr class="empty-row">
<td colspan="7">
No accounts are mapped to this head, so the return reports nothing under it. Map them in
Admin &rarr; RBI Reserve Configuration.
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3"><strong>Total Demand Liabilities ({{ demandLiabilities.length }} accounts)</strong></td>
<td class="right foot-demand">{{ totalOf(demandLiabilities, "openingValue") }}</td>
<td class="right foot-demand">{{ totalOf(demandLiabilities, "currentValue") }}</td>
<td class="right">{{ totalChange(demandLiabilities) }}</td>
<td colspan="3"><strong>Total Demand Liabilities (All 16 Items)</strong></td>
<td class="right foot-demand">12,114.75</td>
<td class="right foot-demand">12,843.50</td>
<td class="right up">+728.75</td>
<td></td>
</tr>
</tfoot>
@@ -207,21 +179,13 @@
</td>
</tr>
}
@empty {
<tr class="empty-row">
<td colspan="7">
No accounts are mapped to this head, so the return reports nothing under it. Map them in
Admin &rarr; RBI Reserve Configuration.
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3"><strong>Total Time Liabilities ({{ timeLiabilities.length }} accounts)</strong></td>
<td class="right foot-time">{{ totalOf(timeLiabilities, "openingValue") }}</td>
<td class="right foot-time">{{ totalOf(timeLiabilities, "currentValue") }}</td>
<td class="right">{{ totalChange(timeLiabilities) }}</td>
<td colspan="3"><strong>Total Time Liabilities (All 13 Items)</strong></td>
<td class="right foot-time">62,613.10</td>
<td class="right foot-time">64,618.00</td>
<td class="right up">+2,004.90</td>
<td></td>
</tr>
</tfoot>
@@ -264,23 +228,15 @@
</td>
</tr>
}
@empty {
<tr class="empty-row">
<td colspan="7">
No accounts are mapped to this head, so the return reports nothing under it. Map them in
Admin &rarr; RBI Reserve Configuration.
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3">
<strong>Total Assets with Banking System</strong> <span class="deduct-tag">( Deducted from NDTL )</span>
</td>
<td class="right foot-asset">{{ totalOf(bankingAssets, "openingValue") }}</td>
<td class="right foot-asset">{{ totalOf(bankingAssets, "currentValue") }}</td>
<td class="right">{{ totalChange(bankingAssets) }}</td>
<td class="right foot-asset">7,600.70</td>
<td class="right foot-asset">8,240.20</td>
<td class="right up">+639.50</td>
<td></td>
</tr>
</tfoot>
@@ -329,21 +285,13 @@
</td>
</tr>
}
@empty {
<tr class="empty-row">
<td colspan="7">
No accounts are mapped to this head, so the return reports nothing under it. Map them in
Admin &rarr; RBI Reserve Configuration.
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="4"><strong>Total Liquid Assets (SLR Eligible)</strong></td>
<td class="right foot-slr">{{ totalOf(liquidAssets, "openingValue") }}</td>
<td class="right foot-slr">{{ totalOf(liquidAssets, "currentValue") }}</td>
<td class="right">{{ totalChange(liquidAssets) }}</td>
<td class="right foot-slr">12,511.70</td>
<td class="right foot-slr">13,238.80</td>
<td class="right up">+727.10</td>
<td></td>
</tr>
</tfoot>
@@ -842,30 +842,3 @@ $slr-b: #bae6fd;
padding: 8px 16px;
}
}
/* Why the figures are missing, in the return's own words — an administrator reading only dashes
goes looking for a fault in this screen rather than in the configuration that actually caused it. */
.unavailable-note {
margin: 0 0 16px;
padding: 10px 14px;
background: #fffbeb;
color: #92400e;
border-radius: 8px;
font-size: 12.5px;
line-height: 1.6;
fa-icon {
margin-right: 8px;
}
}
/* A head with nothing mapped. Said in the table rather than left as an empty body, and it names the
screen where the mapping is made — an empty table otherwise reads as a fault in this one. */
.empty-row td {
padding: 22px 16px;
text-align: center;
color: #6b7280;
font-size: 12.5px;
line-height: 1.6;
background: #fafafa;
}
@@ -6,30 +6,13 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { Component, inject } from '@angular/core';
import { CommonModule, DecimalPipe } from '@angular/common';
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatTabsModule } from '@angular/material/tabs';
import { MatTooltipModule } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { RbiCircularsService } from '../../../rbi-circulars/rbi-circulars.service';
/** One account under a head, at the base date and the reporting date. */
interface CrrSlrRow {
sl: string;
name: string;
sub: string;
code: string;
opening: string;
current: string;
/** The figures themselves, kept so totals add from values rather than from formatted text. */
openingValue: number | null;
currentValue: number | null;
change: string;
changeDir: 'up' | 'dn' | 'flat';
status: string;
}
@Component({
selector: 'mifosx-crr-slr-dialog',
@@ -46,230 +29,477 @@ interface CrrSlrRow {
FaIconComponent
]
})
export class CrrSlrDialogComponent implements OnInit {
export class CrrSlrDialogComponent {
private dialogRef = inject(MatDialogRef<CrrSlrDialogComponent>);
private rbi = inject(RbiCircularsService);
activeTab: 'demand' | 'time' | 'assets' | 'slr' = 'demand';
/** The date the figures were struck at, as the return itself reports it. */
reportingDateStr = '';
/** Reporting fortnight date — 45 days before current date */
today = new Date();
reportingDate: Date;
reportingDateStr: string;
/**
* The close the ratios are charged on — the second preceding fortnight, not the one reported.
*
* Named on screen because the gap between the two is the whole of how the rules work: a bank
* cannot hold a reserve against liabilities it does not yet know, so the requirement is fixed on
* a position already struck and reported. A screen showing one date invites a reader to think the
* requirement was worked out on the day it is shown against.
*/
baseDateStr = '';
/** NDTL & Reserve computations */
readonly totalDemand = 12843.5;
readonly totalTime = 64618.0;
readonly totalBankingAssets = 8240.2;
readonly ndtl = 69221.3; // Demand + Time - Banking Assets
readonly crrRate = 3; // Current RBI CRR rate (%)
readonly slrRate = 18; // Current RBI SLR rate (%)
readonly crrRequired = 69221.3 * 0.03; // ₹2,076.64 Cr
readonly slrRequired = 69221.3 * 0.18; // ₹12,459.83 Cr
readonly totalLiquidAssets = 13238.8;
readonly slrActualPercent = +((13238.8 / 69221.3) * 100).toFixed(2); // 19.12%
readonly slrSurplus = +(13238.8 - 69221.3 * 0.18).toFixed(2); // 778.97
readonly totalReserve = +(69221.3 * 0.03 + 69221.3 * 0.18).toFixed(2); // 14,536.47
loading = true;
/** Set when the return could not be computed — and why, in the return's own words. */
unavailable = '';
/**
* The NDTL computation, read from the return rather than stated here.
*
* These were fixed numbers until now — a demonstration screen that showed ₹69,221 Cr whatever the
* ledger held. The figures are the bank's own regulatory position, so a plausible-looking number
* is worse than no number: it reads as a reported figure and cannot be told apart from one.
*
* Null until the return answers, and left null where it cannot compute. The template shows a dash
* for a null, which says "not reported" where a zero would say "reported as nil".
*/
totalDemand: number | null = null;
totalTime: number | null = null;
totalBankingAssets: number | null = null;
ndtl: number | null = null;
crrRate: number | null = null;
slrRate: number | null = null;
crrRequired: number | null = null;
slrRequired: number | null = null;
totalLiquidAssets: number | null = null;
slrActualPercent: number | null = null;
slrSurplus: number | null = null;
totalReserve: number | null = null;
/**
* A figure for display, or a dash where the return did not carry one.
*
* A dash and a zero say different things — "not reported" against "reported as nil" — and on a
* regulatory screen the difference decides whether anyone goes looking for the missing mapping.
*/
crore(value: number | null): string {
if (value === null) {
return '—';
}
return `₹${value.toLocaleString('en-IN', { maximumFractionDigits: 0 })}`;
}
ngOnInit(): void {
// The return is reported in rupees; this screen reads in crore, as a dashboard tile does.
const toCrore = (v: number | null | undefined): number | null =>
v === null || v === undefined ? null : +(v / 1e7).toFixed(2);
// The statutory Form 1, not the working report. Form 1 is filed twice a month — at the 15th and
// at the month end — and this screen should show the close the bank last filed, not a position
// struck 45 days back by a lag rule that belongs to the other report. Those two disagree by
// weeks, and the 45-day one lands mid-fortnight on a date the bank never reported.
this.rbi.getForm1Statutory().subscribe({
next: (data: any) => {
this.loading = false;
// The later of the two closes the return reports — the one the bank most recently filed.
const columns: string[] = data?.columns ?? [];
const latest = columns.length - 1;
this.reportingDateStr = columns[latest] ?? '';
this.baseDateStr = data?.baseColumns?.[latest] ?? '';
this.crrRate = data?.crrPercentage ?? null;
this.slrRate = data?.slrPercentage ?? null;
if (!columns.length) {
this.unavailable =
data?.unmetRequirements?.[0] ??
'Form 1 could not be computed. Check the CRR/SLR configuration in Admin.';
return;
}
// The return computes but says something would make it wrong to file — a rate never set, a
// base date the ledger cannot answer. Shown rather than swallowed: the reason is the thing
// an administrator needs in order to fix it.
this.unavailable = data?.unmetRequirements?.[0] ?? '';
// Part A's own lines, read by the Roman numeral each carries. Read from the return rather
// than recomputed, so this screen and the filed form cannot come to disagree.
const lines: any[] = data?.partA?.lines ?? [];
const at = (startsWith: string): number | null => {
const line = lines.find((l: any) => String(l?.label ?? '').startsWith(startsWith));
return line?.values?.[latest] ?? null;
};
this.totalDemand = toCrore(at('(A) Demand liabilities'));
this.totalTime = toCrore(at('(B) Time liabilities'));
this.totalBankingAssets = toCrore(at('TOTAL OF III'));
// IV(b) is the base the ratios are actually charged on — the figure behind the requirement,
// not the bank's liabilities on the day being reported.
this.ndtl = toCrore(at('IV. b)'));
this.crrRequired = toCrore(data?.partB?.required?.[latest]);
this.slrRequired = toCrore(data?.partC?.required?.[latest]);
this.totalLiquidAssets = toCrore(data?.partC?.maintained?.[latest]);
if (this.slrRequired !== null && this.totalLiquidAssets !== null) {
this.slrSurplus = +(this.totalLiquidAssets - this.slrRequired).toFixed(2);
}
if (this.ndtl) {
this.slrActualPercent =
this.totalLiquidAssets === null ? null : +((this.totalLiquidAssets / this.ndtl) * 100).toFixed(2);
}
if (this.crrRequired !== null && this.slrRequired !== null) {
this.totalReserve = +(this.crrRequired + this.slrRequired).toFixed(2);
}
this.fillTabs(lines, latest);
},
error: () => {
this.loading = false;
this.unavailable = 'The CRR/SLR position could not be read. The return service did not respond.';
}
constructor() {
// CRR/SLR uses data from 45 days ago
const lagDays = 45;
this.reportingDate = new Date(this.today);
this.reportingDate.setDate(this.reportingDate.getDate() - lagDays);
this.reportingDateStr = this.reportingDate.toLocaleDateString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric'
});
}
/**
* The four tabs, filled from the return's own lines.
*
* These were four fixed lists until now — 435 lines of invented accounts with invented balances,
* which a reader had no way to tell apart from the bank's real position. They are now built from
* the accounts the return actually read, so a tab is empty exactly when the bank has nothing
* mapped under that head, and says so.
*/
demandLiabilities: CrrSlrRow[] = [];
timeLiabilities: CrrSlrRow[] = [];
bankingAssets: CrrSlrRow[] = [];
liquidAssets: CrrSlrRow[] = [];
/**
* Turns Part A's lines into the rows a tab shows.
*
* The form reports each head at both of the closes it carries, so the earlier column is the
* opening and the later one the current — the same pair these tables have always shown, now taken
* from the form rather than invented. Headings and totals are skipped: a heading carries no
* figure, and a total would be added a second time by the footer below.
*/
private rowsFrom(lines: any[], latest: number, match: (label: string) => boolean): CrrSlrRow[] {
const rows: CrrSlrRow[] = [];
for (const line of lines ?? []) {
const label = String(line?.label ?? '');
if (line?.heading || line?.total || !line?.values || !match(label)) {
continue;
}
const opening = latest > 0 ? (line.values[latest - 1] ?? null) : null;
const current = line.values[latest] ?? null;
const change = opening === null || current === null ? null : current - opening;
rows.push({
sl: String(rows.length + 1).padStart(2, '0'),
// The form's own wording, with its numbering trimmed off the front — the table has a column
// of its own for that.
name: label.replace(/^\s*\(?[a-zA-Z0-9]+\)?[.)]?\s*/, '').trim() || label.trim(),
sub: line.configKey ? String(line.configKey).replace(/_/g, ' ') : 'Derived from the form',
code: line.configKey ?? '—',
opening: this.crore(opening),
current: this.crore(current),
openingValue: opening,
currentValue: current,
change: change === null ? '—' : `${change >= 0 ? '+' : ''}${this.crore(change).replace('₹', '')}`,
changeDir: change === null ? 'flat' : change >= 0 ? 'up' : 'dn',
// A head with no GL mapped reports nothing, and the form says so rather than reporting nil.
status: line.configured === false ? 'Not mapped' : 'Active'
});
demandLiabilities = [
{
sl: '01',
name: 'Current Account Deposits',
sub: 'Corporate & individual CA balances',
code: '2001-CA',
opening: '3,842.50',
current: '4,120.75',
change: '+278.25',
changeDir: 'up',
status: 'Active'
},
{
sl: '02',
name: 'Savings Bank Deposits (Withdrawable)',
sub: 'Demand portion — 10% of SB balance',
code: '2002-SB',
opening: '1,240.00',
current: '1,318.40',
change: '+78.40',
changeDir: 'up',
status: 'Active'
},
{
sl: '03',
name: 'Demand Drafts Payable',
sub: 'Issued DDs outstanding for payment',
code: '2003-DD',
opening: '284.20',
current: '311.60',
change: '+27.40',
changeDir: 'up',
status: 'Active'
},
{
sl: '04',
name: "Banker's Cheques / Pay Orders",
sub: 'Issued pay orders pending encashment',
code: '2004-PO',
opening: '198.75',
current: '176.30',
change: '-22.45',
changeDir: 'dn',
status: 'Active'
},
{
sl: '05',
name: 'Credit Balance in Cash Credit Accounts',
sub: 'CC accounts in credit position',
code: '2005-CC',
opening: '542.10',
current: '618.90',
change: '+76.80',
changeDir: 'up',
status: 'Active'
},
{
sl: '06',
name: 'Overdue Fixed Deposits',
sub: 'FDs past maturity — treated as demand',
code: '2006-OFD',
opening: '89.40',
current: '112.85',
change: '+23.45',
changeDir: 'dn',
status: 'Overdue'
},
{
sl: '07',
name: 'Interest Payable on Demand Deposits',
sub: 'Accrued interest on CA/SB',
code: '2007-INT',
opening: '34.20',
current: '38.55',
change: '+4.35',
changeDir: 'up',
status: 'Active'
},
{
sl: '08',
name: 'Margins held against Letters of Credit',
sub: 'Withdrawable LC margin deposits',
code: '2008-LCM',
opening: '412.60',
current: '388.20',
change: '-24.40',
changeDir: 'dn',
status: 'Active'
},
{
sl: '09',
name: 'Margins held against Bank Guarantees',
sub: 'Withdrawable BG margin deposits',
code: '2009-BGM',
opening: '318.40',
current: '342.10',
change: '+23.70',
changeDir: 'up',
status: 'Active'
},
{
sl: '10',
name: 'Security Deposits (Withdrawable)',
sub: 'EMD / SD refundable on demand',
code: '2010-SD',
opening: '124.80',
current: '118.60',
change: '-6.20',
changeDir: 'dn',
status: 'Active'
},
{
sl: '11',
name: 'Unclaimed Deposits (Demand Portion)',
sub: 'Balances unclaimed >10 years',
code: '2011-UCD',
opening: '56.30',
current: '59.40',
change: '+3.10',
changeDir: 'dn',
status: 'Unclaimed'
},
{
sl: '12',
name: 'Inter-Bank Demand Deposits',
sub: 'Demand deposits from other banks',
code: '2012-IBD',
opening: '2,840.00',
current: '3,120.50',
change: '+280.50',
changeDir: 'up',
status: 'Active'
},
{
sl: '13',
name: 'Clearing Adjustments / Balances',
sub: 'Net clearing settlement balances',
code: '2013-CLR',
opening: '186.40',
current: '210.80',
change: '+24.40',
changeDir: 'up',
status: 'Active'
},
{
sl: '14',
name: 'Outstanding Payment Instruments',
sub: 'Cheques issued but not yet presented',
code: '2014-OPI',
opening: '142.60',
current: '158.90',
change: '+16.30',
changeDir: 'up',
status: 'Active'
},
{
sl: '15',
name: 'Demand Liabilities towards Other Banks',
sub: 'Nostro / Vostro demand obligations',
code: '2015-DLB',
opening: '984.20',
current: '1,048.60',
change: '+64.40',
changeDir: 'up',
status: 'Active'
},
{
sl: '16',
name: 'Deposits Repayable on Demand',
sub: 'Miscellaneous on-demand deposits',
code: '2016-DRD',
opening: '618.30',
current: '698.05',
change: '+79.75',
changeDir: 'up',
status: 'Active'
}
return rows;
}
];
/**
* A tab's column total, added from the rows on screen.
*
* Added from the rows rather than stated separately, so the foot of a table always agrees with
* what is above it. The footers used to be fixed text and agreed with nothing.
*
* Summed from each row's own figure, not from the text it displays: a total parsed back out of
* formatted strings would quietly lose the paise the display rounds away.
*/
totalOf(rows: CrrSlrRow[], column: 'openingValue' | 'currentValue'): string {
const values = rows.map((r) => r[column]).filter((v): v is number => v !== null);
return values.length ? this.crore(values.reduce((a, b) => a + b, 0)) : '—';
}
/** The movement across a tab, between the two closes the form reports. */
totalChange(rows: CrrSlrRow[]): string {
const opening = rows.map((r) => r.openingValue).filter((v): v is number => v !== null);
const current = rows.map((r) => r.currentValue).filter((v): v is number => v !== null);
if (!opening.length || !current.length) {
return '—';
timeLiabilities = [
{
sl: '01',
name: 'Fixed Deposits (FD)',
sub: '7 days to 10 years · 6.5–7.2%',
code: '3001-FD',
opening: '28,420.50',
current: '29,184.30',
change: '+763.80',
changeDir: 'up',
status: 'Active'
},
{
sl: '02',
name: 'Recurring Deposits (RD)',
sub: 'Monthly instalment · 6.0–6.8%',
code: '3002-RD',
opening: '4,218.60',
current: '4,412.80',
change: '+194.20',
changeDir: 'up',
status: 'Active'
},
{
sl: '03',
name: 'Time Liability Portion of Savings',
sub: '90% of SB balance treated as time',
code: '3003-SBT',
opening: '10,840.20',
current: '11,204.60',
change: '+364.40',
changeDir: 'up',
status: 'Active'
},
{
sl: '04',
name: 'Reinvestment Deposits',
sub: 'Cumulative interest plans · 7.0%',
code: '3004-RID',
opening: '1,842.40',
current: '1,918.70',
change: '+76.30',
changeDir: 'up',
status: 'Active'
},
{
sl: '05',
name: 'Staff Security Deposits (Fixed Period)',
sub: 'Employee security deposits — locked',
code: '3005-SSD',
opening: '84.60',
current: '86.40',
change: '+1.80',
changeDir: 'up',
status: 'Locked'
},
{
sl: '06',
name: 'Margin Money for Guarantees / LC',
sub: 'LC/BG margins — fixed lock-in period',
code: '3006-MGM',
opening: '620.80',
current: '644.20',
change: '+23.40',
changeDir: 'up',
status: 'Locked'
},
{
sl: '07',
name: 'Certificates of Deposit (CD)',
sub: 'Issued to corporates / FIs · 7.35%',
code: '3007-CD',
opening: '3,840.00',
current: '3,920.50',
change: '+80.50',
changeDir: 'up',
status: 'Active'
},
{
sl: '08',
name: 'Interest Accrued on Time Deposits',
sub: 'Accrued but unpaid interest on FD/RD',
code: '3008-IAT',
opening: '842.60',
current: '918.40',
change: '+75.80',
changeDir: 'up',
status: 'Active'
},
{
sl: '09',
name: 'Long-term Deposits',
sub: 'Tenor >5 years · 7.5%',
code: '3009-LTD',
opening: '5,620.40',
current: '5,818.60',
change: '+198.20',
changeDir: 'up',
status: 'Active'
},
{
sl: '10',
name: 'Tax Saving Deposits',
sub: 'Sec 80C FDs — 5-year lock-in · 6.8%',
code: '3010-TSD',
opening: '1,240.80',
current: '1,318.40',
change: '+77.60',
changeDir: 'up',
status: 'Locked'
},
{
sl: '11',
name: 'Foreign Currency Deposits',
sub: 'FCNR(B), RFC — USD/EUR/GBP',
code: '3011-FCD',
opening: '2,184.60',
current: '2,348.20',
change: '+163.60',
changeDir: 'up',
status: 'Active'
},
{
sl: '12',
name: 'Time Liabilities to Other Banks',
sub: 'Inter-bank term deposits — call money',
code: '3012-TLB',
opening: '1,840.20',
current: '1,924.40',
change: '+84.20',
changeDir: 'up',
status: 'Active'
},
{
sl: '13',
name: 'Term Borrowings',
sub: 'Bonds, debentures, refinance · 8.1%',
code: '3013-TBR',
opening: '1,018.40',
current: '1,118.50',
change: '+100.10',
changeDir: 'up',
status: 'Active'
}
const change = current.reduce((a, b) => a + b, 0) - opening.reduce((a, b) => a + b, 0);
return `${change >= 0 ? '+' : ''}${this.crore(change).replace('₹', '')}`;
}
];
/** Fills the four tabs from one reading of the form. */
private fillTabs(lines: any[], latest: number): void {
const has = (label: string, ...words: string[]): boolean => {
const l = label.toLowerCase();
return words.some((w) => l.includes(w));
};
// Matched on how the form labels each head, not on a fixed list of account names: a bank that
// maps a new account to a head gets it on the right tab without this screen being changed.
this.demandLiabilities = this.rowsFrom(lines, latest, (l) => has(l, 'demand liabilit'));
this.timeLiabilities = this.rowsFrom(lines, latest, (l) => has(l, 'time liabilit') && !has(l, 'demand and time'));
// III — what the banking system owes this bank, which nets off the liability base.
this.bankingAssets = this.rowsFrom(lines, latest, (l) =>
has(l, 'current account balances with sbi', 'balances in all accounts', 'money at call', 'advances and any other')
);
// V, VI and VII — the cash, balances and securities the two ratios are met in.
this.liquidAssets = this.rowsFrom(lines, latest, (l) =>
has(l, 'cash on hand', 'reserve bank of india', 'co-operative bank', 'government securities')
);
}
bankingAssets = [
{
sl: '01',
name: 'Balances with Other Banks',
sub: 'Nostro / Vostro balances — SBI, PNB, BOB',
code: '4001-BWB',
opening: '3,840.20',
current: '4,112.40',
change: '+272.20',
changeDir: 'up',
status: 'Active'
},
{
sl: '02',
name: 'Money at Call with Banks',
sub: 'Overnight & short-notice call money',
code: '4002-MCB',
opening: '2,180.50',
current: '2,418.60',
change: '+238.10',
changeDir: 'up',
status: 'Active'
},
{
sl: '03',
name: 'Loans to Banks (Inter-Bank Lending)',
sub: 'Term loans & credit lines to SCBs',
code: '4003-LTB',
opening: '1,580.00',
current: '1,709.20',
change: '+129.20',
changeDir: 'up',
status: 'Active'
}
];
liquidAssets = [
{
sl: '01',
name: 'Central Government Securities',
sub: 'G-Secs held in HTM, AFS & HFT portfolios',
code: '5001-CGS',
opening: '6,840.50',
current: '7,218.40',
change: '+377.90',
changeDir: 'up',
status: 'Active',
category: 'G-Sec'
},
{
sl: '02',
name: 'State Development Loans (SDLs)',
sub: 'State govt bonds — various maturities',
code: '5002-SDL',
opening: '2,420.30',
current: '2,584.60',
change: '+164.30',
changeDir: 'up',
status: 'Active',
category: 'SDL'
},
{
sl: '03',
name: 'Treasury Bills (91/182/364 day)',
sub: 'Short-term govt securities — discounted',
code: '5003-TB',
opening: '1,840.20',
current: '1,962.80',
change: '+122.60',
changeDir: 'up',
status: 'Active',
category: 'T-Bill'
},
{
sl: '04',
name: 'Gold (valued at current market)',
sub: 'Gold reserves held per RBI valuation norms',
code: '5004-GLD',
opening: '680.40',
current: '724.60',
change: '+44.20',
changeDir: 'up',
status: 'Active',
category: 'Gold'
},
{
sl: '05',
name: 'Special Deposit Scheme with RBI',
sub: 'Deposits placed under SDS — SLR eligible',
code: '5005-SDS',
opening: '420.00',
current: '420.00',
change: '0.00',
changeDir: 'up',
status: 'Active',
category: 'RBI Dep'
},
{
sl: '06',
name: 'NABARD / SIDBI / NHB Bonds',
sub: 'PSE bonds approved as SLR-eligible',
code: '5006-PSE',
opening: '310.80',
current: '328.40',
change: '+17.60',
changeDir: 'up',
status: 'Active',
category: 'PSE Bond'
}
];
close(): void {
this.dialogRef.close();
@@ -28,30 +28,17 @@
<fa-icon icon="external-link-alt"></fa-icon> View
</button>
</div>
<!-- Read from the return, not stated here. Both the rates and the amounts were fixed text
until now, so the card reported ₹2,077 Cr at 3% whatever the ledger held and whatever
rate Admin was on. -->
<div class="card-metrics">
<div class="metric-row">
<span class="metric-label">CRR Required{{ crrSlr.crrRate === null ? '' : ' (' + crrSlr.crrRate + '%)' }}</span>
<span class="metric-value">{{ crrSlr.crrRequired }}</span>
<span class="metric-label">CRR Required (3%)</span>
<span class="metric-value">&#8377;2,077 Cr</span>
</div>
<div class="metric-row">
<span class="metric-label">SLR Required{{ crrSlr.slrRate === null ? '' : ' (' + crrSlr.slrRate + '%)' }}</span>
<span class="metric-value">{{ crrSlr.slrRequired }}</span>
<span class="metric-label">SLR Required (18%)</span>
<span class="metric-value">&#8377;12,460 Cr</span>
</div>
</div>
<!-- "Within Limits" is a compliance statement, so it is made only where the return actually
says so. Where it cannot compute, the card says that instead of passing the bank. -->
<div class="mini-sub">
@if (crrSlr.status === 'ok') {
<span class="tag-success">Within Limits</span>
} @else if (crrSlr.status === 'short') {
<span class="tag-danger">Shortfall</span>
} @else {
<span class="tag-muted">Not computed</span>
}
</div>
<div class="mini-sub"><span class="tag-success">Within Limits</span></div>
</div>
<!-- CRAR -->
@@ -444,17 +444,6 @@ $purple: #7c3aed;
font-weight: 600;
}
/* A figure that could not be computed. Deliberately not green: a tile that cannot read the ledger
has not found the bank compliant, and a success tag on an unknown position is a false all-clear. */
.tag-muted {
background: #f3f4f6;
color: #4b5563;
padding: 2px 6px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
}
/* ─── Compact Detail Cards ─── */
.dash-card.compact {
padding: 12px 16px;
@@ -19,7 +19,6 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { AsyncPipe, DatePipe } from '@angular/common';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RbiCircularsService } from '../../rbi-circulars/rbi-circulars.service';
/**
* Dashboard component.
*/
@@ -41,29 +40,9 @@ import { RbiCircularsService } from '../../rbi-circulars/rbi-circulars.service';
export class DashboardComponent implements OnInit {
private router = inject(Router);
private dialog = inject(MatDialog);
private rbi = inject(RbiCircularsService);
today = new Date();
/**
* The CRR/SLR tile, read from the return.
*
* The amounts and the rates were both fixed text on the template until now, so the tile reported
* ₹2,077 Cr at 3% whatever the ledger held and whatever rate Admin was on. A dashboard figure is
* read as the bank's position, and one that cannot move is worse than none: nothing on the tile
* says it is a placeholder.
*
* Dashes until the return answers. 'status' drives the compliance tag, which is only claimed
* where the return states it.
*/
crrSlr: {
crrRate: number | null;
slrRate: number | null;
crrRequired: string;
slrRequired: string;
status: 'ok' | 'short' | 'unknown';
} = { crrRate: null, slrRate: null, crrRequired: '—', slrRequired: '—', status: 'unknown' };
/** Array of all user activities */
userActivity: string[];
/** Array of most recent user activities */
@@ -88,40 +67,6 @@ export class DashboardComponent implements OnInit {
this.recentActivities = this.getRecentActivities();
this.frequentActivities = this.getFrequentActivities();
this.setFilteredActivities();
this.loadCrrSlr();
}
/** Fills the CRR/SLR tile from the return, leaving it dashed where the return cannot compute. */
private loadCrrSlr(): void {
const crore = (v: number | null | undefined): string =>
v === null || v === undefined ? '—' : `₹${Math.round(v / 1e7).toLocaleString('en-IN')} Cr`;
// The statutory Form 1, which is the return this bank files — twice a month, at the 15th and at
// the month end. The working report's 45-day lag lands mid-fortnight, on a date the bank never
// reported, so a tile built on it shows a position that appears on no filing.
this.rbi.getForm1Statutory().subscribe({
next: (data: any) => {
const latest = (data?.columns?.length ?? 0) - 1;
if (latest < 0) {
return;
}
this.crrSlr.crrRate = data?.crrPercentage ?? null;
this.crrSlr.slrRate = data?.slrPercentage ?? null;
this.crrSlr.crrRequired = crore(data?.partB?.required?.[latest]);
this.crrSlr.slrRequired = crore(data?.partC?.required?.[latest]);
// Compliant only where BOTH ratios are met: the tile carries one tag for the pair, and a
// bank short on SLR is not within limits because its cash reserve happens to be ample.
const crrHeld = data?.partB?.maintained?.[latest];
const crrNeed = data?.partB?.required?.[latest];
const slrHeld = data?.partC?.maintained?.[latest];
const slrNeed = data?.partC?.required?.[latest];
if ([crrHeld, crrNeed, slrHeld, slrNeed].every((v) => v !== null && v !== undefined)) {
this.crrSlr.status = crrHeld >= crrNeed && slrHeld >= slrNeed ? 'ok' : 'short';
}
},
error: () => undefined
});
}
/**
@@ -52,7 +52,7 @@
}
} @else {
<span class="rrc-rate-now__value rrc-rate-now__value--none">Not set</span>
<span class="rrc-rate-now__since">{{ type === 'CRR' ? 'Form 1 Part B' : 'Form 1 Part C' }} cannot compute</span>
<span class="rrc-rate-now__since">{{ type === 'CRR' ? 'Form 1' : 'Form 9' }} cannot compute</span>
}
</div>
}
@@ -62,10 +62,8 @@
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-rate-form__type">
<mat-label>Rate</mat-label>
<mat-select [(ngModel)]="newRate.rateType">
<!-- Both ratios are Form 1's. Part B charges section 18 on the base, Part C section
24 on the same base - one form, two parts, not two returns. -->
<mat-option value="CRR">CRR — Form 1 Part B</mat-option>
<mat-option value="SLR">SLR — Form 1 Part C</mat-option>
<mat-option value="CRR">CRR — Form 1</mat-option>
<mat-option value="SLR">SLR — Form 9</mat-option>
<!-- Not a reserve ratio, but set by RBI and effective-dated the same way: the share of
savings balances that counts as a demand liability, the rest being time. -->
<mat-option value="SAVINGS_DEMAND">Savings — demand share</mat-option>
@@ -23,11 +23,8 @@ export interface ReserveMapping {
}
/**
* Configuration behind RBI Form 1: the percentages RBI sets, and which GL accounts the return reads
* its figures from.
*
* One form settles both ratios. Part B is section 18 (the cash reserve) and Part C is section 24
* (the liquid assets), charged on the same base, so CRR and SLR are configured together here.
* Configuration behind RBI Form 1 (CRR) and Form 9 (SLR): the percentages RBI sets, and which GL
* accounts the returns read their figures from.
*
* Both halves sit on one screen because they are one decision. A percentage applies to a base, and
* an administrator who sets the rate but never maps the accounts produces a return that looks
@@ -50,8 +47,8 @@ export class RbiReserveConfigComponent implements OnInit {
private organizationService = inject(OrganizationService);
/**
* Which return's roles this screen configures, as the backend groups them: 'RESERVE' for Form 1,
* 'FORM9' for Form IX, 'OSS1', 'OSS2'. Null shows every role.
* Which return's roles this screen configures, as the backend groups them: 'RESERVE' for Form 1
* and Form 9, 'OSS1', 'OSS2'. Null shows every role.
*
* A screen per return rather than one screen for all of them: the four roles that drive CRR and
* the thirty that do not are separate decisions, made by different people at different times,
@@ -68,14 +65,13 @@ export class RbiReserveConfigComponent implements OnInit {
roleGroups: { name: string; label: string; roles: any[] }[] = [];
/**
* Which return's configuration is on screen: Form 1, Form IX, or both together.
* Which return's configuration is on screen: Form 1, Form 9, or both together.
*
* The two returns have nothing in common - Form 1 charges ratios on a liability base, Form IX
* reports a balance sheet - so an administrator setting one up is looking at a different set of
* heads than one setting up the other. Showing all of them at once buries the handful that matter
* for the task in hand.
* The two returns share a base but maintain their requirement in different assets, so an
* administrator setting one up is looking at a different set of heads than one setting up the
* other. Showing all twenty-six at once buries the four that matter for the task in hand.
*/
formFilter: 'ALL' | 'FORM_1' | 'FORM_IX' = 'ALL';
formFilter: 'ALL' | 'FORM_1' | 'FORM_9' | 'FORM_IX' = 'ALL';
/** Every reserve role the backend returned, before the form filter narrows it. */
private allRoles: any[] = [];
@@ -99,28 +95,20 @@ export class RbiReserveConfigComponent implements OnInit {
return scoped.filter((r: any) => this.belongsToSelectedForm(r));
}
/**
* The returns this bank files, and the only ones that can be configured.
*
* There is no separate SLR return. Form 1 settles both ratios on one form - Part B is section 18,
* the cash reserve, and Part C is section 24, the liquid assets - so the heads that meet SLR are
* Form 1's heads. A "Form 9 - SLR" option used to sit here from before that was understood; it
* selected a return that has no report behind it, and filtered the SLR heads out of the one form
* that actually reports them.
*/
readonly formFilters = [
{ value: 'ALL', label: 'All returns' },
{ value: 'FORM_1', label: 'Form 1 — CRR & SLR' },
{ value: 'FORM_1', label: 'Form 1 — CRR (fortnightly)' },
{ value: 'FORM_9', label: 'Form 9 — SLR (monthly)' },
{ value: 'FORM_IX', label: 'Form IX — Assets & Liabilities (R408)' }
];
/**
* Whether a head belongs to the return currently selected.
*
* Form 1 takes every head that is not Form IX's. It reports both ratios: MAINTAINED heads are met
* in cash and balances and settle Part B, SLR_ASSET heads are met in securities and gold and
* settle Part C, and the heads that build or net the DTL base feed the IV(b) both rates are
* charged on. Splitting them across two filters only hid half of one form.
* Decided from how the return consumes the head rather than from its name: MAINTAINED is met in
* cash and balances and so is Form 1's, SLR_ASSET is met in securities and gold and so is Form
* 9's, and a head that builds or nets the DTL base belongs to both because both charge their rate
* on that same base. A memo head is stated on either return and so shows on both.
*/
private belongsToSelectedForm(role: any): boolean {
if (this.formFilter === 'ALL') {
@@ -137,7 +125,19 @@ export class RbiReserveConfigComponent implements OnInit {
if (this.formFilter === 'FORM_IX') {
return false;
}
// Everything left belongs to Form 1, whichever of its two parts it settles.
// The built-in SLR investments role predates the SLR_ASSET usage and still carries MAINTAINED,
// so it is placed by name; every other head is placed by how the return consumes it.
if (role.configKey === 'SLR_INVESTMENTS') {
return this.formFilter === 'FORM_9';
}
const usage = role.usage || role.roleUsage;
if (usage === 'MAINTAINED') {
return this.formFilter === 'FORM_1';
}
if (usage === 'SLR_ASSET') {
return this.formFilter === 'FORM_9';
}
// LIABILITY, ASSET_NET and MEMO all appear on both returns.
return true;
}
@@ -171,15 +171,15 @@ export class RbiReserveConfigComponent implements OnInit {
/**
* How a head an administrator adds is consumed by the return.
*
* MAINTAINED and SLR_ASSET are separate because the two parts admit different assets: CRR is met
* in cash and balances with banks, SLR in cash, gold and approved securities. One option for both
* would let a head meant for Part B quietly satisfy Part C.
* MAINTAINED and SLR_ASSET are separate because the two returns admit different assets: CRR is
* met in cash and balances with banks, SLR in cash, gold and approved securities. One option for
* both would let a head meant for Form 1 quietly satisfy Form 9.
*/
readonly roleUsages = [
{ value: 'LIABILITY', label: 'Liability — adds to the DTL base (Form 1)' },
{ value: 'ASSET_NET', label: 'Asset — netted off the base to give NDTL (Form 1)' },
{ value: 'MAINTAINED', label: 'Counts toward CRR maintained (Form 1 Part B)' },
{ value: 'SLR_ASSET', label: 'Counts toward SLR maintained (Form 1 Part C)' },
{ value: 'LIABILITY', label: 'Liability — adds to the DTL base (Form 1 & 9)' },
{ value: 'ASSET_NET', label: 'Asset — netted off the base to give NDTL (Form 1 & 9)' },
{ value: 'MAINTAINED', label: 'Counts toward CRR maintained (Form 1)' },
{ value: 'SLR_ASSET', label: 'Counts toward SLR maintained (Form 9)' },
// Form IX has no ratio and no base: its usages say which side of the balance sheet a head sits
// on. LIABILITY above serves its liability side, and this its asset side.
{ value: 'ASSET', label: 'Asset — enters Total Assets (Form IX)' },
@@ -282,9 +282,9 @@ export class RbiReserveConfigComponent implements OnInit {
get screenSubtitle(): string {
return (
'The CRR and SLR percentages, and the GL accounts RBI Form 1 reads its figures from — Part B settles the cash ' +
'reserve, Part C the liquid assets, both charged on the same base. A rate applies to a base — so a role left ' +
'unmapped stops the return computing rather than reporting a nil figure that looks real.'
'The CRR and SLR percentages, and the GL accounts that RBI Form 1 (CRR, fortnightly) and Form 9 (SLR, monthly) ' +
'read their figures from. A rate applies to a base — so a role left unmapped stops its return computing rather ' +
'than reporting a nil figure that looks real.'
);
}
+27 -27
View File
@@ -15,43 +15,43 @@ $gray-900: #0f172a;
// ── Container ──
.pd-container {
max-width: 1100px;
max-width: 1280px;
margin: 0 auto;
padding: 24px 20px;
padding: 12px 16px;
}
// ── Page Header ──
.pd-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 28px;
gap: 10px;
margin-bottom: 14px;
&__icon {
width: 44px;
height: 44px;
border-radius: 10px;
width: 34px;
height: 34px;
border-radius: 7px;
background: linear-gradient(135deg, #0d3d78, #1a5276);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 18px;
font-size: 14px;
flex-shrink: 0;
}
&__title {
font-size: 20px;
font-size: 16px;
font-weight: 700;
color: $gray-900;
margin: 0;
letter-spacing: -0.3px;
letter-spacing: -0.2px;
}
&__sub {
font-size: 13px;
font-size: 11.5px;
color: $gray-500;
margin: 2px 0 0;
margin: 1px 0 0;
}
}
@@ -59,7 +59,7 @@ $gray-900: #0f172a;
.pd-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
gap: 8px;
@media (width <= 768px) {
grid-template-columns: 1fr;
@@ -70,11 +70,11 @@ $gray-900: #0f172a;
.pd-card {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
gap: 10px;
padding: 8px 12px;
background: #fff;
border: 1px solid $border;
border-radius: 10px;
border-radius: 7px;
text-decoration: none;
cursor: pointer;
transition: all 0.15s ease;
@@ -82,7 +82,7 @@ $gray-900: #0f172a;
&:hover {
border-color: #cbd5e1;
background: $gray-50;
box-shadow: 0 2px 8px rgb(0 0 0 / 4%);
box-shadow: 0 1px 6px rgb(0 0 0 / 4%);
.pd-card__arrow {
color: $gray-700;
@@ -91,13 +91,13 @@ $gray-900: #0f172a;
}
&__icon {
width: 40px;
height: 40px;
border-radius: 9px;
width: 30px;
height: 30px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-size: 12.5px;
flex-shrink: 0;
&--blue {
@@ -167,25 +167,25 @@ $gray-900: #0f172a;
}
&__title {
font-size: 13.5px;
font-size: 12px;
font-weight: 600;
color: $gray-900;
margin: 0;
line-height: 1.3;
line-height: 1.25;
}
&__desc {
font-size: 11.5px;
font-size: 10.5px;
color: $gray-400;
margin: 3px 0 0;
line-height: 1.35;
margin: 1px 0 0;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__arrow {
font-size: 11px;
font-size: 10px;
color: #cbd5e1;
flex-shrink: 0;
transition: all 0.15s ease;
-11
View File
@@ -27,17 +27,6 @@ export function solidFill(argb: string): ExcelJS.Fill {
return { type: 'pattern', pattern: 'solid', fgColor: { argb } };
}
/**
* The diagonal hatching RBI's templates put on a cell that is waiting to be filled in by hand.
*
* It reads differently from an empty cell, and the difference matters on a statutory form: an empty
* cell says nothing was reported, hatching says someone still has to report it. The templates use
* it on the signatory block and on the particulars a bank writes in itself.
*/
export function hatchFill(): ExcelJS.Fill {
return { type: 'pattern', pattern: 'lightUp', fgColor: { argb: 'FF808080' }, bgColor: { argb: 'FFFFFFFF' } };
}
/** Dark-blue full-width banner that heads every sheet in the RBI templates. */
export function addBanner(sheet: ExcelJS.Worksheet, title: string, lastCol: string): void {
const row = sheet.addRow([title]);
+1 -12
View File
@@ -177,9 +177,7 @@ export class RbiCircularsService {
reportingDate?: string,
firstColumnDate?: string,
secondColumnDate?: string,
officeId?: number,
firstBaseDate?: string,
secondBaseDate?: string
officeId?: number
): Observable<any> {
let params = new HttpParams();
if (reportingDate) {
@@ -194,15 +192,6 @@ export class RbiCircularsService {
if (secondColumnDate) {
params = params.set('secondColumnDate', secondColumnDate);
}
// The base each column's ratios are charged on. Left blank the rules fix it — the last Friday
// of that column's second preceding fortnight — so a filer only names one where the rule
// produces a date their ledger cannot answer, such as a period before the bank migrated.
if (firstBaseDate) {
params = params.set('firstBaseDate', firstBaseDate);
}
if (secondBaseDate) {
params = params.set('secondBaseDate', secondBaseDate);
}
if (officeId != null) {
params = params.set('officeId', String(officeId));
}
@@ -68,10 +68,7 @@ export class Form1CrrComponent extends ReserveReturnBase implements OnInit {
return this.rbiService.getForm1Statutory(
this.statutoryDate,
this.statutoryFirstColumn || undefined,
this.statutorySecondColumn || undefined,
undefined,
this.statutoryFirstBase || undefined,
this.statutorySecondBase || undefined
this.statutorySecondColumn || undefined
);
}
@@ -115,18 +112,12 @@ export class Form1CrrComponent extends ReserveReturnBase implements OnInit {
const filled = this.asDate(year, month, monthEnd);
this.statutorySecondColumn = filled > this.today ? this.today : filled;
this.statutoryDate = this.statutorySecondColumn;
// The bases follow the closes, so a quick fill leaves a complete form rather than two dates
// and two empty boxes the filer has to work out for themselves.
this.onStatutoryColumnChange('first');
this.onStatutoryColumnChange('second');
}
/** Clears both columns and the bases that follow from them. */
/** Clears both columns, so they are derived from the reporting date instead. */
clearStatutoryColumns(): void {
this.statutoryFirstColumn = '';
this.statutorySecondColumn = '';
this.statutoryFirstBase = '';
this.statutorySecondBase = '';
}
private asDate(year: number, month: number, day: number): string {
@@ -78,38 +78,18 @@ $red: #c5221f;
line-height: 1.7;
}
// The date pairs. Each row holds two, and the rows stack — so the closes sit directly above the
// bases they produce, and a filer reads down a column rather than across a wrapped line.
&__row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
& + & {
margin-top: 10px;
}
// The bases are filled from the closes above rather than typed, so they are set back a shade -
// enough to read as "these follow from those" without looking disabled, since they can be
// changed and sometimes must be.
&--derived {
opacity: 0.85;
}
}
&__date {
width: 220px;
}
// The button on its own line under the pairs, centred on them rather than trailing the last one.
&__go {
margin-top: 16px;
display: flex;
justify-content: center;
}
&__hint {
margin: 14px 0 0;
font-size: 12.5px;
@@ -268,18 +248,6 @@ $red: #c5221f;
text-transform: uppercase;
color: $gray-500;
}
// The date the column's ratios are charged on. In the heading rather than in a column of its own,
// so a reader sees which base produced the required amounts below without the base's balances
// sitting beside the closes' as though they were a third period.
&__colbase {
display: block;
margin-top: 1px;
font-weight: 400;
font-size: 9px;
letter-spacing: 0.02em;
color: $primary;
}
}
// Part banner — PART A / PART B / PART C.
@@ -339,107 +307,6 @@ $red: #c5221f;
white-space: nowrap;
}
// ── Day by day ──
//
// Outside the form's own sheet, because it is not part of what gets filed: the form reports two
// closes, and this is the working behind them.
.f1s-daily {
margin-top: 24px;
background: #fff;
border: 1px solid $border;
border-radius: 10px;
padding: 18px 20px;
&__title {
margin: 0 0 4px;
font-size: 14px;
font-weight: 700;
color: $gray-900;
}
// A day short is a breach, and on thirty rows it is exactly what the eye skips. Said in the
// heading so it is read before the table rather than found in it.
&__flag {
display: inline-block;
margin-left: 10px;
padding: 2px 8px;
font-size: 10.5px;
font-weight: 700;
color: $red;
background: #fce8e6;
border-radius: 4px;
}
&__hint {
margin: 0 0 14px;
font-size: 11.5px;
color: $gray-500;
}
&__stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 16px;
}
// The base a day was charged on. Set back because it repeats for every day of a fortnight and
// would otherwise compete with the figures it qualifies.
&__base {
color: $gray-500;
font-size: 11px;
}
// A day the bank's own liabilities moved. The whole point of carrying a daily figure is to find
// these, and on thirty rows of near-identical numbers they are invisible unless marked.
&__moved {
font-weight: 700;
color: $primary;
background: #f0f6ff;
}
&__table {
font-size: 11.5px;
th {
font-size: 10.5px;
}
}
&--short td {
background: #fff6f6;
}
}
.f1s-stat {
border: 1px solid $border;
border-radius: 8px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 2px;
&__label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $gray-500;
font-weight: 600;
}
&__value {
font-size: 17px;
font-weight: 600;
color: $gray-900;
font-variant-numeric: tabular-nums;
}
&__sub {
font-size: 10.5px;
color: $gray-500;
}
}
.f1s-sign {
margin-top: 56px;
text-align: right;
@@ -10,7 +10,7 @@ import { inject } from '@angular/core';
import { Observable, catchError, forkJoin, of } from 'rxjs';
import * as ExcelJS from 'exceljs';
import { RbiCircularsService } from '../rbi-circulars.service';
import { addBanner, boxAllCells, downloadWorkbook, hatchFill, newWorkbook, solidFill, XL } from '../oss-workbook';
import { addBanner, boxAllCells, downloadWorkbook, newWorkbook, solidFill, XL } from '../oss-workbook';
/** One configured figure on the return, with the accounts it was derived from. */
export interface ReserveLine {
@@ -97,72 +97,13 @@ export interface Form1Part {
baseDateLiabilities?: number[];
}
/** One day of the period, with both ratios measured against the base in force that day. */
export interface Form1DailyRow {
date: string;
/** The date this day's ratios were charged on — it moves at each fortnight boundary. */
baseDate: string;
/**
* The net liabilities that date held, which both requirements below are struck on.
*
* Absent on a return saved before the backend stated it, so readers treat it as optional rather
* than printing a zero where an older snapshot simply never carried the figure.
*/
baseLiabilities?: number;
/**
* The day's own net liabilities, which move daily where the base holds still for a fortnight.
*
* Optional for the same reason as the base: a return saved before the backend stated it carries
* no figure, and a zero would read as a day with no liabilities rather than as one not recorded.
*/
ownLiabilities?: number;
crrRequired: number;
crrMaintained: number;
/** Negative where the day fell short. */
crrSurplus: number;
slrRequired: number;
slrMaintained: number;
slrSurplus: number;
}
/**
* The day-by-day position behind the form's two columns.
*
* Both ratios are reckoned over the period, not on the closes alone, so a form whose columns pass
* can still sit on days that did not. The short-day counts say so outright rather than leaving them
* to be spotted in a table of thirty rows.
*/
export interface Form1Daily {
from: string;
to: string;
days: Form1DailyRow[];
dayCount: number;
crrAverage: number;
slrAverage: number;
crrLowest: number;
crrLowestOn: string | null;
slrLowest: number;
slrLowestOn: string | null;
crrShortDays: number;
slrShortDays: number;
}
export interface Form1Statutory {
formCode: string;
formName: string;
statutoryReference: string;
reportingDate: string;
/** The fortnight closes the form reports, in time order. */
/** The two fortnight closes and the reporting date, in time order. */
columns: string[];
/**
* The date each column's ratios are charged on, in the same order.
*
* Absent on a return saved before the base stopped being a column of its own, so the screen reads
* it defensively rather than assuming every snapshot carries it.
*/
baseColumns?: string[];
/** Every day of the period, with what each ratio required and what was held. */
daily?: Form1Daily;
crrPercentage: number | null;
slrPercentage: number | null;
partA: Form1Part;
@@ -423,8 +364,8 @@ export abstract class ReserveReturnBase {
readonly statutoryTitle: string = 'Form 1 — Sections 18(1) and 24(3)';
readonly statutorySubtitle: string =
'Banking Regulation (Co-operative Societies) Rules, 1966. The form reports two fortnight closes ' +
'side by side — for September that is 15/09 and 30/09 — and charges both ratios on the matching ' +
'close one month earlier.';
'side by side — for August that is 15/08 and 31/08 — and charges both ratios on the last Friday ' +
'of the second preceding fortnight.';
// ── Statutory form state ──
//
@@ -447,75 +388,6 @@ export abstract class ReserveReturnBase {
statutoryFirstColumn = '';
statutorySecondColumn = '';
/**
* The base date each column's ratios are charged on.
*
* Filled in from the rule as soon as a close is entered — the last Friday of that close's second
* preceding fortnight — and editable from there. Shown rather than hidden because every required
* amount on the form is a percentage of the liabilities on this date, and a reviewer cannot check
* a requirement whose base they cannot see. Editable because a bank that migrated part-way
* through a period has a base the rule produces and the ledger cannot answer.
*/
statutoryFirstBase = '';
statutorySecondBase = '';
/**
* The base date a close is charged on: the previous month's matching fortnight close.
*
* Which fortnight the column falls in settles it, not the day it carries. A date in the first
* half — the 1st to the 15th — charges on the 15th of the previous month; anything later charges
* on the previous month's END, whatever day that is. So 30/09 charges on 31/08, and so does
* 20/09: both sit in the second fortnight, and the second fortnight's close is the month end.
*
* Keyed on the fortnight rather than on the day number because the two closes are not the same
* kind of date. The 15th is a fixed day; the month end moves between 28 and 31. Matching day for
* day would charge 30/09 on 30/08, a day that closes nothing.
*
* RBI's own wording is "the last Friday of the second preceding fortnight", and that rule was
* tried here. It was taken out again because the dates it produced did not match the returns this
* bank has already filed by hand — it puts the base on a weekday that moves and roughly six weeks
* back. The fortnight closes this bank actually files on are the 15th and the month end.
*
* Mirrors the backend, which computes the same date for the figures. Kept here so the prompt can
* fill the base in as the close is typed; a base entered by hand still overrides it.
*/
baseDateFor(closeDate: string): string {
if (!closeDate) {
return '';
}
const d = new Date(closeDate + 'T00:00:00');
if (Number.isNaN(d.getTime())) {
return '';
}
// A date input hands over whatever has been typed so far, and a half-typed year parses as a
// real date: "0026-09-30" is year 26, which the Date constructor then reads as 1926, because
// it adds 1900 to any year from 0 to 99. The base silently came back a century out. Nothing
// before 1900 is a date this form has any use for, so it is treated as still being typed.
if (d.getFullYear() < 1900) {
return '';
}
// The previous month, reached by taking day 0 of this one: month is zero-based, so this lands
// on the last day of the month before.
const previousMonthEnd = new Date(d.getFullYear(), d.getMonth(), 0);
if (d.getDate() <= 15) {
return this.asIsoDate(new Date(previousMonthEnd.getFullYear(), previousMonthEnd.getMonth(), 15));
}
return this.asIsoDate(previousMonthEnd);
}
private asIsoDate(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
/** Fills a column's base from the rule, leaving one the filer has already set alone. */
onStatutoryColumnChange(which: 'first' | 'second'): void {
if (which === 'first') {
this.statutoryFirstBase = this.baseDateFor(this.statutoryFirstColumn);
} else {
this.statutorySecondBase = this.baseDateFor(this.statutorySecondColumn);
}
}
/** Fetches the statutory form. Overridden by each return that has one. */
protected fetchStatutory(): Observable<any> {
return of(null);
@@ -525,16 +397,7 @@ export abstract class ReserveReturnBase {
generateStatutory(): void {
if (this.statutoryTakesTwoColumns) {
if (!this.statutoryFirstColumn || !this.statutorySecondColumn) {
this.statutoryError = 'Enter both fortnight closing dates — for September that is 15/09 and 30/09.';
return;
}
// A half-typed year parses as a real date — "30-09-0026" is year 26 — and then fails the
// ordering check below with a message about column order, which is not what went wrong.
// Caught first so the filer is told about the year rather than sent looking at the dates.
const halfTyped = [this.statutoryFirstColumn, this.statutorySecondColumn, this.statutoryFirstBase, this.statutorySecondBase]
.filter((d) => d && Number(d.slice(0, 4)) < 1900);
if (halfTyped.length) {
this.statutoryError = `The year in ${halfTyped[0]} looks incomplete — enter all four digits.`;
this.statutoryError = 'Enter both fortnight closing dates — for August that is 15/08 and 31/08.';
return;
}
if (this.statutoryFirstColumn >= this.statutorySecondColumn) {
@@ -547,16 +410,6 @@ export abstract class ReserveReturnBase {
this.statutoryError = `The second column (${this.statutorySecondColumn}) has not happened yet — today is ${this.today}.`;
return;
}
// A base dated after the close it serves would charge the ratio on liabilities the bank had
// not taken on yet — arithmetically fine, and meaningless.
if (this.statutoryFirstBase && this.statutoryFirstBase > this.statutoryFirstColumn) {
this.statutoryError = `The first column's base date (${this.statutoryFirstBase}) falls after the close it is charged on.`;
return;
}
if (this.statutorySecondBase && this.statutorySecondBase > this.statutorySecondColumn) {
this.statutoryError = `The second column's base date (${this.statutorySecondBase}) falls after the close it is charged on.`;
return;
}
// The return is made up to the later of the two closes, so the reporting date follows from
// them rather than being a third date a filer has to keep consistent with the first two.
this.statutoryDate = this.statutorySecondColumn;
@@ -1313,11 +1166,12 @@ export abstract class ReserveReturnBase {
`;
/**
* The statutory form as the workbook RBI files it in — the CIMS R377 template.
* The statutory form as a workbook, laid out as the form is printed.
*
* Every figure comes from the return as generated, so the workbook follows whatever dates were
* asked for: change the closes and the whole of it moves with them. Nothing here is a figure
* typed into a template by hand.
* This is the form that gets filed, so it has to leave the screen in a shape a filer can hand on:
* the same three dated columns, the same Roman-numbered lines, the same parts in the same order.
* A workbook that reorganised the form would have to be read back against the screen line by line
* before anyone could trust it.
*/
async exportStatutory(): Promise<void> {
if (!this.statutory) {
@@ -1326,584 +1180,94 @@ export abstract class ReserveReturnBase {
this.generating = true;
try {
const wb = newWorkbook();
this.addCimsGeneralInformation(wb);
this.addCimsPartA(wb);
this.addCimsOthersSheet(wb);
this.addCimsPartBandC(wb);
this.addCimsPartD(wb);
this.addCimsSecuritiesSheet(wb, 'Part C.XII. c');
this.addCimsSecuritiesSheet(wb, 'Part D.XIV.e');
this.addCimsDailySheet(wb, 'CRR');
this.addCimsDailySheet(wb, 'SLR');
this.addCimsSignatory(wb);
// The working behind the two closes, after the template's own sheets. CIMS reads the sheets
// it knows by name and ignores the rest, so carrying it costs the filing nothing and saves
// producing it again when an inspector asks.
this.addDailySheet(wb);
await downloadWorkbook(wb, `R377_Form1_${this.statutory.reportingDate}`);
const sheet = wb.addWorksheet(this.statutoryTabLabel);
sheet.columns = [
{ width: 64 },
{ width: 20 },
{ width: 20 },
{ width: 20 }];
addBanner(sheet, this.statutory.formName, 'D');
const ref = sheet.addRow([this.statutory.statutoryReference]);
sheet.mergeCells(ref.number, 1, ref.number, 4);
ref.font = { size: 9, italic: true };
ref.alignment = { horizontal: 'center' };
sheet.addRow([]);
let start = sheet.rowCount + 1;
this.addKeyValue(sheet, 'Made up to', this.statutory.reportingDate);
this.addKeyValue(
sheet,
'CRR rate',
this.statutory.crrPercentage === null ? 'Not set' : `${this.statutory.crrPercentage}%`
);
this.addKeyValue(
sheet,
'SLR rate',
this.statutory.slrPercentage === null ? 'Not set' : `${this.statutory.slrPercentage}%`
);
boxAllCells(sheet, start, sheet.rowCount, 2);
sheet.addRow([]);
// Anything that would make the form wrong to file goes above the figures, not below them:
// a caveat under a full statement is read after the reader has already believed it.
if (this.statutory.unmetRequirements.length) {
const warn = sheet.addRow(['Check before filing:']);
warn.font = { bold: true, color: { argb: 'FFB06000' } };
for (const reason of this.statutory.unmetRequirements) {
const r = sheet.addRow([` ${reason}`]);
r.font = { size: 9, color: { argb: 'FFB06000' } };
}
sheet.addRow([]);
}
start = sheet.rowCount + 1;
// The third column is the base the ratios are charged on, not a third close — said on the
// header so the workbook cannot be read as carrying three reporting dates.
const head = sheet.addRow([
'Particulars',
...this.statutory.columns.map((col, i) => `${col}\n${i === 2 ? 'base for ratios' : 'fortnight close'}`)
]);
head.font = { bold: true };
head.alignment = { wrapText: true, horizontal: 'center' };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const part of this.statutoryParts) {
const banner = sheet.addRow([part.title]);
sheet.mergeCells(banner.number, 1, banner.number, 4);
banner.font = { bold: true, color: { argb: XL.bannerText } };
banner.eachCell((c) => (c.fill = solidFill(XL.banner)));
for (const line of part.lines) {
// The form's own indentation carries its numbering — (a), (i), (A) — so a flattened
// sheet would lose which line belongs under which head.
const row = sheet.addRow([
`${' '.repeat(line.indent ?? 0)}${line.label}`,
...(line.values ?? []).map((v) => v)
]);
row.getCell(1).alignment = { wrapText: true };
if (line.heading || line.total) {
row.font = { bold: true };
}
for (let c = 2; c <= 4; c++) {
row.getCell(c).numFmt = '#,##0.00';
row.getCell(c).alignment = { horizontal: 'right' };
}
}
this.addStatutoryTotals(sheet, 'Required', part.required);
this.addStatutoryTotals(sheet, 'Maintained', part.maintained);
this.addStatutoryTotals(sheet, 'Surplus / (shortfall)', part.surplus);
}
boxAllCells(sheet, start, sheet.rowCount, 4);
await downloadWorkbook(wb, `${this.exportStem}_${this.statutory.reportingDate}`);
} finally {
this.generating = false;
}
}
// ── The CIMS R377 workbook ──
//
// RBI takes Form 1 through CIMS, which does not read a spreadsheet the way a person does: it
// looks for its own sheets, by its own names, carrying its own wording. A single sheet that read
// like the screen — which is what this used to export — is rejected before anyone looks at a
// figure in it. So the workbook below is the template's, line for line, and only the figures and
// the dates come from the return. Sheets this bank has nothing to put on (Part D is for scheduled
// banks) are still written, empty: CIMS checks that a sheet exists before it checks its rows.
/** The dated columns the return reports, which every sheet below is laid out across. */
private get cimsDates(): string[] {
return this.statutory?.columns ?? [];
}
private addCimsGeneralInformation(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('General Information');
sheet.columns = [{ width: 40 }, { width: 52 }];
addBanner(sheet, 'General Information', 'B');
const start = sheet.rowCount + 1;
// Return name, code and version are the template's own and are what CIMS matches on, so they
// are the one place in this workbook where a fixed string is the correct value.
this.addKeyValue(sheet, 'Return Name', 'New_Statement of Compliance with CRR and SLR (Form - I)');
this.addKeyValue(sheet, 'Return Code', 'R377');
// Blank where the bank's own particulars belong. They are filed-entity details this system does
// not hold, and a guess printed on a statutory return reads as a statement of fact.
this.addKeyValue(sheet, 'Bank Name', '');
this.addKeyValue(sheet, 'Bank Code', '');
this.addKeyValue(sheet, 'Address of Reporting Institution', '');
this.addKeyValue(sheet, 'RO Name', '');
this.addKeyValue(sheet, 'Reporting Frequency', 'Monthly');
this.addKeyValue(sheet, 'Month End Date', this.statutory?.reportingDate ?? '');
this.addKeyValue(sheet, 'Bank Type', '');
this.addKeyValue(sheet, 'Validation Status', '');
this.addKeyValue(sheet, 'Return Version', 'V1.0');
boxAllCells(sheet, start, sheet.rowCount, 2);
// Anything that would make the return wrong to file goes on the first sheet, where it is read
// before the figures rather than found after them.
if (this.statutory?.unmetRequirements?.length) {
sheet.addRow([]);
const head = sheet.addRow(['Check before filing:']);
head.font = { bold: true, color: { argb: 'FFB06000' } };
for (const reason of this.statutory.unmetRequirements) {
const row = sheet.addRow([reason]);
sheet.mergeCells(row.number, 1, row.number, 2);
row.alignment = { wrapText: true };
row.font = { size: 9, color: { argb: 'FFB06000' } };
}
}
}
/** Part A — the liabilities and assets, Roman-numbered I through VIII, across the dated columns. */
private addCimsPartA(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('Part A');
sheet.columns = [{ width: 72 }, ...this.cimsDates.map(() => ({ width: 22 }))];
addBanner(sheet, 'Part A', this.cimsLastColumn());
sheet.addRow([]);
this.addCimsEndDates(sheet, 'End Date');
const start = sheet.rowCount + 1;
for (const line of this.statutory?.partA?.lines ?? []) {
// The form's own indentation carries its numbering — (a), then (i) — so a flattened sheet
// would lose which line sits under which head.
const row = sheet.addRow([`${' '.repeat(line.indent ?? 0)}${line.label}`, ...(line.values ?? [])]);
row.getCell(1).alignment = { wrapText: true };
if (line.heading || line.total) {
row.font = { bold: true };
}
this.formatCimsFigures(row);
this.shadeCimsRow(sheet, row, line.heading === true, line.total === true || line.values !== undefined);
}
boxAllCells(sheet, start, sheet.rowCount, this.cimsDates.length + 1);
}
/**
* Part A.II.c — the break-up of "Liabilities in India to Others: c) Others".
*
* Written with its heading and a nil total: the return does not break that head down, and a sheet
* omitted fails the upload where a sheet carrying an honest nil does not.
*/
private addCimsOthersSheet(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('Part A.II.c');
sheet.columns = [{ width: 10 }, { width: 50 }, ...this.cimsDates.map(() => ({ width: 22 }))];
addBanner(sheet, 'Part A.II.c', this.columnLetter(this.cimsDates.length + 2));
sheet.addRow([]);
const start = sheet.rowCount + 1;
const last = this.cimsDates.length + 2;
// "As at the close of Business on" heads BOTH dated columns as one caption, as the template has
// it — written once and merged across them, not repeated over each.
const head = sheet.addRow(['Sr.No.', 'Particulars', 'As at the close of Business on']);
head.font = { bold: true };
head.alignment = { wrapText: true, horizontal: 'center' };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
if (this.cimsDates.length > 1) {
sheet.mergeCells(head.number, 3, head.number, last);
}
for (let c = 3; c <= last; c++) {
head.getCell(c).fill = solidFill(XL.headerGrey);
}
const dates = sheet.addRow(['', '', ...this.cimsDates]);
dates.font = { bold: true };
dates.alignment = { horizontal: 'center' };
for (let c = 3; c <= last; c++) {
dates.getCell(c).fill = solidFill(XL.headerGrey);
}
// Sr.No. 1, hatched, as the template carries it: the break-up is written in by hand, so the row
// has to be there to be written in. An empty sheet with only a Total says the bank reported
// nothing; a hatched row says nobody has filled it in yet.
const entry = sheet.addRow([1, '', ...this.cimsDates.map((): null => null)]);
for (let c = 2; c <= last; c++) {
entry.getCell(c).fill = hatchFill();
}
const total = sheet.addRow(['', 'Total', ...this.cimsDates.map(() => 0)]);
total.font = { bold: true };
total.getCell(1).fill = solidFill(XL.headerGrey);
total.getCell(2).alignment = { horizontal: 'center' };
total.getCell(2).fill = solidFill(XL.headerGrey);
this.formatCimsFigures(total, 3);
for (let c = 3; c <= last; c++) {
total.getCell(c).fill = solidFill(XL.derivedBlue);
}
boxAllCells(sheet, start, sheet.rowCount, last);
}
/** Parts B and C on one sheet, as the template has them. */
private addCimsPartBandC(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('Part B and C');
sheet.columns = [{ width: 72 }, ...this.cimsDates.map(() => ({ width: 22 }))];
addBanner(sheet, 'Part B and C', this.cimsLastColumn());
sheet.addRow([]);
this.addCimsEndDates(sheet, 'End Date');
const start = sheet.rowCount + 1;
const crr = this.statutory?.crrPercentage;
const slr = this.statutory?.slrPercentage;
this.addCimsSection(sheet, 'Part B: Compliance with section 18 (Applicable to only non-scheduled UCBs)');
this.addCimsFigures(
sheet,
`IX. CRR required to be maintained (prescribed ${this.cimsRate(crr)} of IV b) — ${this.cimsBaseDates()})`,
this.statutory?.partB?.required
);
this.addCimsFigures(sheet, 'X. Cash Reserve actually maintained', this.statutory?.partB?.maintained);
this.addCimsSection(sheet, 'Part C: Compliance with section 24 (Applicable to only non-scheduled UCBs)');
this.addCimsFigures(
sheet,
`XI. SLR required to be maintained (prescribed ${this.cimsRate(slr)} of IV b) — ${this.cimsBaseDates()})`,
this.statutory?.partC?.required
);
this.addCimsSection(sheet, 'XII. SLR Actually Maintained');
// Read off Part C's own lines rather than recomputed here, so this sheet and the screen cannot
// come to differ about what made the total up.
this.addCimsFigures(
sheet,
'a) Cash and Other balances maintained in India (Excess cash reserve maintained but the excess not from DCCBs and StCBs)',
this.cimsPartCLine('(a) Cash and other balances')
);
this.addCimsFigures(sheet, 'b) Gold', this.cimsPartCLine('(b) Gold'));
this.addCimsFigures(sheet, 'c) Unencumbered Approved Securities', this.cimsPartCLine('(c) Unencumbered'));
this.addCimsFigures(sheet, 'Total of (XII)', this.statutory?.partC?.maintained, true);
boxAllCells(sheet, start, sheet.rowCount, this.cimsDates.length + 1);
}
/**
* Part D — section 24 as scheduled banks file it.
*
* This is a non-scheduled co-operative bank, which files Parts B and C; Part D is not its return
* to make. It is written with the template's rows and nil figures rather than left out, because
* what CIMS rejects is a missing sheet, not an empty one.
*/
private addCimsPartD(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('Part D');
sheet.columns = [{ width: 72 }, ...this.cimsDates.map(() => ({ width: 22 }))];
addBanner(sheet, 'Part D', this.cimsLastColumn());
sheet.addRow([]);
this.addCimsEndDates(sheet, 'End Date');
const start = sheet.rowCount + 1;
// Nil throughout, because this bank does not file Part D — but shaded as the template shades
// it, so the sheet still reads the way a filer expects: blue where the form would compute a
// figure, white where a bank would write one in.
const nil = this.cimsDates.map(() => 0);
this.addCimsSection(sheet, 'Part D: Compliance with section 24 (Applicable to only scheduled UCBs)');
this.addCimsFigures(
sheet,
'XIII. SLR required to be maintained (prescribed % of IV as on Last Friday of the Second Preceding fortnight)',
nil
);
this.addCimsSection(sheet, 'XIV. Assets actually maintained');
this.addCimsFigures(sheet, 'a) Cash in hand', nil);
this.addCimsFigures(
sheet,
'b) Balance maintained with RBI in excess of the balance required to be maintained under section 42 of the RBI Act 1934'
);
this.addCimsFigures(sheet, 'c) Net Balance in Current Accounts (i.e. VIII)', nil);
this.addCimsFigures(sheet, 'd) Gold');
this.addCimsFigures(sheet, 'e) Unencumbered Approved Securities $$', nil);
this.addCimsFigures(sheet, 'f) State Co-op Bank of the State');
this.addCimsFigures(sheet, 'g) District Central Co-op Bank');
this.addCimsFigures(sheet, 'Total of (XIV) excluding f & g', nil, true);
boxAllCells(sheet, start, sheet.rowCount, this.cimsDates.length + 1);
}
/**
* The securities valuation sheets — Part C.XII.c and Part D.XIV.e.
*
* Four valuation columns per period, and the template repeats the whole block for each date. The
* rows are the movement of the holding over the fortnight; this return strikes a closing position
* and does not carry the movement that produced it, so the opening and the additions are written
* nil and the closing carries the figure. A reader then sees the holding that was counted, and
* sees that the movement was not reported rather than being told it was zero.
*/
private addCimsSecuritiesSheet(wb: ExcelJS.Workbook, name: string): void {
const perDate = 4;
const dates = this.cimsDates;
const sheet = wb.addWorksheet(name);
sheet.columns = [{ width: 38 }, ...dates.flatMap(() => Array(perDate).fill({ width: 19 }))];
addBanner(sheet, name, this.columnLetter(dates.length * perDate + 1));
sheet.addRow([]);
const dateRow = sheet.addRow(['', ...dates.flatMap((d) => [d, '', '', ''])]);
dateRow.font = { bold: true };
dates.forEach((_, i) => {
const from = 2 + i * perDate;
sheet.mergeCells(dateRow.number, from, dateRow.number, from + perDate - 1);
dateRow.getCell(from).alignment = { horizontal: 'center' };
dateRow.getCell(from).fill = solidFill(XL.auditYellow);
});
const start = sheet.rowCount + 1;
const head = sheet.addRow([
'',
...dates.flatMap(() => ['Face Value', 'Book Value', 'Depreciation', 'Net Value for SLR purpose (2-3)'])
]);
head.font = { bold: true };
head.alignment = { wrapText: true, horizontal: 'center' };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
const numbers = sheet.addRow(['', ...dates.flatMap(() => [1, 2, 3, 4])]);
numbers.font = { bold: true };
numbers.alignment = { horizontal: 'center' };
// Part C's own securities line, so the valuation sheet totals to the figure the compliance
// sheet claimed. Depreciation is nil because the return counts securities at the value the
// ledger holds, which is already net of it.
const held = this.cimsPartCLine('(c) Unencumbered') ?? dates.map(() => 0);
const movement = (label: string, carries: boolean, closing = false) => {
const cells: (string | number)[] = [label];
for (let i = 0; i < dates.length; i++) {
const value = carries ? held[i] ?? 0 : 0;
cells.push(value, value, 0, value);
}
const row = sheet.addRow(cells);
row.getCell(1).fill = solidFill(XL.headerGrey);
if (closing) {
row.font = { bold: true };
}
for (let c = 2; c <= dates.length * perDate + 1; c++) {
row.getCell(c).numFmt = '#,##0.00';
row.getCell(c).alignment = { horizontal: 'right' };
// The template shades the Net Value column on every row — it is the figure that counts for
// SLR — and the whole of a closing balance row, which is the line the return reads off.
const isNetValue = (c - 1) % perDate === 0;
if (closing || isNetValue) {
row.getCell(c).fill = solidFill(XL.derivedBlue);
}
}
};
this.addCimsSection(sheet, 'Part - I (Government Securities)');
movement('Opening Balance', false);
movement('Addition during the fortnights', false);
movement('Deduction during the fortnights', false);
movement('Closing Balance (a)', true, true);
this.addCimsSection(sheet, 'Part - II (Other Approved Securities)');
movement('Opening Balance', false);
movement('Addition during the fortnights', false);
movement('Deduction during the fortnights', false);
movement('Closing Balance (b)', false, true);
movement('Closing Balance (a+b)', true, true);
boxAllCells(sheet, start, sheet.rowCount, dates.length * perDate + 1);
}
/**
* The daily maintenance sheet the template carries one of per ratio.
*
* One row a day, under the template's own columns: the base liabilities, what was required, what
* was held, and the gap. The gap is split into a Deficit column and a Surplus column rather than
* carried as one signed figure, because that is how the template reads it and a filer copying
* between the two should not have to re-sign anything.
*/
private addCimsDailySheet(wb: ExcelJS.Workbook, ratio: 'CRR' | 'SLR'): void {
const isCrr = ratio === 'CRR';
const sheet = wb.addWorksheet(isCrr ? 'Daily CRR maintd (NS)' : 'Daily SLR maintd (S and NS)');
sheet.columns = [
{ width: 9 },
{ width: 14 },
{ width: 26 },
{ width: 21 },
{ width: 21 },
{ width: 21 },
{ width: 21 },
{ width: 19 },
{ width: 26 }
];
addBanner(
sheet,
isCrr
? 'Cash Reserve Ratio Amount [Applicable to only Non-Scheduled UCBs]'
: 'Statutory Liquidity Ratio Amount [Applicable to both Scheduled and Non-Scheduled UCBs]',
'I'
);
sheet.addRow([]);
const start = sheet.rowCount + 1;
// The template bands the ratio's name across the four columns it governs, above their own
// headings — the first three columns are the day and its base, and belong to neither ratio.
const title = isCrr
? 'Cash Reserve Ratio Amount [Applicable to only Non-Scheduled UCBs]'
: 'Statutory Liquidity Ratio Amount [Applicable to both Scheduled and Non-Scheduled UCBs]';
const band = sheet.addRow(['', '', '', title]);
band.font = { bold: true };
band.alignment = { wrapText: true, horizontal: 'center' };
sheet.mergeCells(band.number, 4, band.number, 9);
for (let c = 1; c <= 9; c++) {
band.getCell(c).fill = solidFill(XL.headerGrey);
}
const head = sheet.addRow([
'Sr. No.',
'Date',
'Net Demand and Time Liabilities (As on the base date)',
'Required to be maintained',
'Actually maintained',
'Deficit (Required-Actual)',
'Surplus (Actual-Required)',
`${ratio} Interest (Actual Rs.)`,
'Remarks'
]);
head.font = { bold: true };
head.alignment = { wrapText: true, horizontal: 'center' };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
// The first three columns are one heading spanning both rows, as the template has them.
for (const c of [1, 2, 3]) {
sheet.mergeCells(band.number, c, head.number, c);
}
const days = this.statutory?.daily?.days ?? [];
days.forEach((day, i) => {
const required = isCrr ? day.crrRequired : day.slrRequired;
const held = isCrr ? day.crrMaintained : day.slrMaintained;
const gap = isCrr ? day.crrSurplus : day.slrSurplus;
const row = sheet.addRow([
i + 1,
day.date,
// Blank, not zero, where an older snapshot never carried the base: a nil in this column
// would read as a day with no liabilities, which is a different claim from not knowing.
day.baseLiabilities ?? '',
required,
held,
gap < 0 ? -gap : 0,
gap > 0 ? gap : 0,
'',
// The template gives NDTL one column, and it is the base's — so the day's own liabilities
// go in Remarks rather than in a column the template does not have. A reader can still see
// which days the bank's position moved without the sheet departing from the filed shape.
this.cimsDailyRemark(day, gap)
]);
for (let c = 3; c <= 8; c++) {
row.getCell(c).numFmt = '#,##0.00';
row.getCell(c).alignment = { horizontal: 'right' };
}
// A day short is the thing this sheet exists to show, and in a column of near-identical
// figures it is exactly what the eye goes past.
if (gap < 0) {
row.font = { color: { argb: 'FFC5221F' } };
}
});
boxAllCells(sheet, start, sheet.rowCount, 9);
}
/**
* What a daily row's Remarks column says: the base the day was charged on, the day's own
* liabilities, and whether it fell short.
*/
private cimsDailyRemark(day: Form1DailyRow, gap: number): string {
const parts = [`Charged on ${day.baseDate}`];
if (day.ownLiabilities !== undefined) {
parts.push(`NDTL on the day ${day.ownLiabilities.toFixed(2)}`);
}
if (gap < 0) {
parts.push('SHORT');
}
return parts.join('; ');
}
private addCimsSignatory(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('Signatory Information');
sheet.columns = [{ width: 26 }, { width: 36 }, { width: 36 }];
addBanner(sheet, 'Signatory Information', 'C');
sheet.addRow([]);
const start = sheet.rowCount + 1;
const head = sheet.addRow(['', 'Authorised Reporting Official', 'Countersigned By']);
head.font = { bold: true };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
// Left to be signed, and hatched the way the template hatches it so the blank reads as "fill
// this in" rather than as a field the return had nothing to say about. Who signs a filed return,
// and on what date, is settled when it is filed — a name pre-filled here would put a signature
// on a form nobody had agreed to sign.
for (const field of ['Name', 'Designation', 'e-mail ID', 'Tel. No. (O)', 'Tel. No. (R)', 'Place', 'Date']) {
const row = sheet.addRow([field, '', '']);
row.getCell(1).font = { bold: true };
row.getCell(1).fill = solidFill(XL.keyCell);
row.getCell(2).fill = hatchFill();
row.getCell(3).fill = hatchFill();
}
boxAllCells(sheet, start, sheet.rowCount, 3);
}
/**
* The header every CIMS data sheet opens with: the dates its columns report.
*
* Yellow, as the template has it — the one thing on the sheet a filer sets, so it is the one
* thing not shaded like the figures below it.
*/
private addCimsEndDates(sheet: ExcelJS.Worksheet, label: string): void {
const row = sheet.addRow([label, ...this.cimsDates]);
row.font = { bold: true };
row.getCell(1).alignment = { horizontal: 'right' };
for (let c = 2; c <= this.cimsDates.length + 1; c++) {
row.getCell(c).alignment = { horizontal: 'center' };
row.getCell(c).fill = solidFill(XL.auditYellow);
}
}
/**
* The template's shading, which is what makes one of its sheets readable at a glance.
*
* Three states, and the colour is the only thing that tells them apart: every label cell is grey,
* a figure the form computes is blue, and a figure a bank writes in by hand is left white. On a
* sheet where most of the numbers are 0.00 that is the whole of the information — blue says "this
* is nil because the form worked it out", white says "nobody has filled this in". An all-white
* sheet makes those two look identical.
*/
private shadeCimsRow(sheet: ExcelJS.Worksheet, row: ExcelJS.Row, heading: boolean, computed: boolean): void {
row.getCell(1).fill = solidFill(XL.headerGrey);
const last = this.cimsDates.length + 1;
for (let c = 2; c <= last; c++) {
if (heading) {
// A heading has no figures of its own; the template greys the whole band.
row.getCell(c).fill = solidFill(XL.headerGrey);
} else if (computed) {
row.getCell(c).fill = solidFill(XL.derivedBlue);
}
}
}
private addCimsSection(sheet: ExcelJS.Worksheet, label: string): void {
const row = sheet.addRow([label]);
row.font = { bold: true };
row.getCell(1).alignment = { wrapText: true };
row.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
}
/**
* One figure line across the dated columns.
*
* A line the return computes is written and shaded blue. A line it does not carry is left white
* and empty rather than filled with zeros: on Part D, which this bank does not file, a column of
* 0.00 claims the bank worked the figure out and found nothing, when in truth it never reported
* that part at all.
*/
private addCimsFigures(sheet: ExcelJS.Worksheet, label: string, values?: number[], bold = false): void {
const row = sheet.addRow([label, ...(values ?? this.cimsDates.map((): null => null))]);
row.getCell(1).alignment = { wrapText: true };
if (bold) {
row.font = { bold: true };
}
this.formatCimsFigures(row);
this.shadeCimsRow(sheet, row, false, values !== undefined);
}
private formatCimsFigures(row: ExcelJS.Row, from = 2): void {
for (let c = from; c < from + this.cimsDates.length; c++) {
row.getCell(c).numFmt = '#,##0.00';
row.getCell(c).alignment = { horizontal: 'right' };
}
}
/**
* The rate a requirement was struck at, as it is named on the form.
*
* "rate not set" rather than a blank or a zero: a requirement computed at no rate is not a
* requirement of nothing, and a statutory line that reads "0% of IV b)" states a rate the bank
* was never given.
*/
private cimsRate(rate: number | null | undefined): string {
return rate === null || rate === undefined ? 'rate not set' : `${rate}%`;
}
/**
* The base dates the two columns are charged on, for the wording of the requirement lines.
*
* The template says "Last Friday of the Second Preceding fortnight". This bank's base rule is the
* previous month's matching fortnight close, so the dates themselves are printed instead — a
* filer can check them, where a Friday that was never used cannot be checked at all.
*/
private cimsBaseDates(): string {
const bases = this.statutory?.baseColumns ?? [];
return bases.length ? bases.join(' / ') : 'base date not stated';
}
/**
* Whether the bank's own liabilities moved on this day from the day before.
*
* The daily table is thirty rows that mostly repeat, and the days worth looking at are the ones
* where something changed. Marked rather than left to be spotted: a reader scanning a column of
* near-identical figures will not find the row where the fourth digit differs.
*
* The first day has nothing before it in the table, so it is never marked — it is a starting
* position, not a movement.
*/
dailyOwnMoved(day: Form1DailyRow, index: number): boolean {
if (index === 0 || day.ownLiabilities === undefined) {
return false;
}
const previous = this.statutory?.daily?.days?.[index - 1]?.ownLiabilities;
return previous !== undefined && previous !== day.ownLiabilities;
}
/** A part's line, found by the opening of its label, as its values across the dated columns. */
private cimsPartCLine(startsWith: string): number[] | undefined {
return this.statutory?.partC?.lines?.find((line) => (line.label ?? '').startsWith(startsWith))?.values;
}
private cimsLastColumn(): string {
return this.columnLetter(this.cimsDates.length + 1);
}
/** Spreadsheet column letter for a 1-based index — A, B, … Z, AA. */
private columnLetter(index: number): string {
let n = index;
let letters = '';
while (n > 0) {
const remainder = (n - 1) % 26;
letters = String.fromCharCode(65 + remainder) + letters;
n = Math.floor((n - 1) / 26);
}
return letters;
}
/**
* Form 1 saves the statutory form, because that is the only Form 1 this screen shows.
*
@@ -1912,104 +1276,6 @@ export abstract class ReserveReturnBase {
* the reader built for the other shape.
*/
/**
* The day-by-day position, as its own sheet in the workbook.
*
* Written only where the return carries it, so a saved snapshot taken before this existed still
* exports rather than failing on a sheet it has no rows for.
*/
private addDailySheet(wb: ExcelJS.Workbook): void {
const daily = this.statutory?.daily;
if (!daily?.days?.length) {
return;
}
const sheet = wb.addWorksheet('Day by day');
sheet.columns = [
{ width: 14 },
{ width: 18 },
{ width: 14 },
{ width: 18 },
{ width: 18 },
{ width: 18 },
{ width: 20 },
{ width: 18 },
{ width: 18 },
{ width: 20 }
];
addBanner(sheet, `Day by day — ${daily.from} to ${daily.to}`, 'J');
// A day short is a breach. It goes above the table, where it is read before the figures rather
// than hunted for among thirty rows.
if (daily.crrShortDays || daily.slrShortDays) {
const warn = sheet.addRow([
`CRR short on ${daily.crrShortDays} of ${daily.dayCount} days; SLR short on ${daily.slrShortDays}.`
]);
warn.font = { bold: true, color: { argb: 'FFC5221F' } };
sheet.addRow([]);
}
const start = sheet.rowCount + 1;
const head = sheet.addRow([
'Date',
'NDTL on the day',
'Charged on',
'Base NDTL',
'CRR required',
'CRR maintained',
'CRR surplus / (short)',
'SLR required',
'SLR maintained',
'SLR surplus / (short)'
]);
head.font = { bold: true };
head.alignment = { wrapText: true, horizontal: 'center' };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const day of daily.days) {
const row = sheet.addRow([
day.date,
day.ownLiabilities ?? '',
day.baseDate,
day.baseLiabilities ?? '',
day.crrRequired,
day.crrMaintained,
day.crrSurplus,
day.slrRequired,
day.slrMaintained,
day.slrSurplus
]);
// Column 3 is a date, so the figures run 2, then 4 onward.
for (const c of [2, 4, 5, 6, 7, 8, 9, 10]) {
row.getCell(c).numFmt = '#,##0.00';
row.getCell(c).alignment = { horizontal: 'right' };
}
// A shortfall is marked on the row, not only by its sign: a minus in a column of figures is
// the easiest thing on a printed sheet to read past.
if (day.crrSurplus < 0 || day.slrSurplus < 0) {
row.font = { color: { argb: 'FFC5221F' } };
}
}
sheet.addRow([]);
// Under the Maintained columns the averages belong to — column 6 for CRR, column 9 for SLR.
const avg = sheet.addRow([
'Average held',
'',
'',
'',
'',
daily.crrAverage,
'',
'',
daily.slrAverage,
''
]);
avg.font = { bold: true };
avg.getCell(6).numFmt = '#,##0.00';
avg.getCell(9).numFmt = '#,##0.00';
boxAllCells(sheet, start, sheet.rowCount, 10);
}
/** One of a part's computed rows, written only where the part carries it. */
private addStatutoryTotals(sheet: ExcelJS.Worksheet, label: string, values?: number[]): void {
if (!values?.length) {
@@ -2020,9 +1286,7 @@ export abstract class ReserveReturnBase {
...values
]);
row.font = { bold: true };
// The values themselves say how many columns there are, so this row formats what it wrote.
const lastColumn = values.length + 1;
for (let c = 2; c <= lastColumn; c++) {
for (let c = 2; c <= 4; c++) {
row.getCell(c).numFmt = '#,##0.00';
row.getCell(c).alignment = { horizontal: 'right' };
}
@@ -534,51 +534,12 @@
heading, so the picker does not offer one. -->
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="f1s-prompt__date">
<mat-label>First column</mat-label>
<input
matInput
type="date"
[max]="today"
[(ngModel)]="statutoryFirstColumn"
(ngModelChange)="onStatutoryColumnChange('first')"
required
/>
<input matInput type="date" [max]="today" [(ngModel)]="statutoryFirstColumn" required />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="f1s-prompt__date">
<mat-label>Second column</mat-label>
<input
matInput
type="date"
[max]="today"
[(ngModel)]="statutorySecondColumn"
(ngModelChange)="onStatutoryColumnChange('second')"
required
/>
<input matInput type="date" [max]="today" [(ngModel)]="statutorySecondColumn" required />
</mat-form-field>
</div>
<!--
The base each column's ratios are charged on. Filled from the rule as soon as a close is
entered — the matching close one month earlier — and editable from
there. Shown rather than hidden because every required amount on the form is a percentage
of the liabilities on this date, and a requirement whose base nobody can see cannot be
checked. Editable because a bank that migrated part-way through has a base the rule
produces and the ledger cannot answer, and the ratio then reads nil against real
liabilities.
-->
<div class="f1s-prompt__row f1s-prompt__row--derived">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="f1s-prompt__date">
<mat-label>Base for first column</mat-label>
<input matInput type="date" [max]="statutoryFirstColumn || today" [(ngModel)]="statutoryFirstBase" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="f1s-prompt__date">
<mat-label>Base for second column</mat-label>
<input matInput type="date" [max]="statutorySecondColumn || today" [(ngModel)]="statutorySecondBase" />
</mat-form-field>
</div>
<!-- On its own line, under both pairs. Sharing a row with the last pair left it hanging
off one corner of a 2×2 grid, reading as a field's own button rather than the form's. -->
<div class="f1s-prompt__go">
<button
mat-raised-button
color="primary"
@@ -589,14 +550,17 @@
</button>
</div>
<!-- The base date follows from the dates entered rather than being asked for: it is fixed
by the rules at the last Friday of the second preceding fortnight, so letting anyone
type a different one would put a figure on a filed form that the rules do not allow. -->
@if (statutoryFirstColumn && statutorySecondColumn) {
<p class="f1s-prompt__hint">
Made up to <strong>{{ statutorySecondColumn }}</strong
>. The base dates above are the matching close one month earlier — change either
one if the bank files on a different base.
>. Both ratios are charged on the last Friday of the second preceding fortnight, stated on the form
itself.
</p>
} @else {
<p class="f1s-prompt__hint">Enter both fortnight closing dates — the base dates fill in from them.</p>
<p class="f1s-prompt__hint">Enter both fortnight closing dates to run the form.</p>
}
<!-- A filed return almost always carries one month's two closes, so both fill in one click. -->
@@ -622,7 +586,7 @@
@if (statutoryDate) {
<p class="f1s-prompt__hint">
The return reports the month ending on or before <strong>{{ statutoryDate }}</strong
>, and charges the ratio on the matching close one month earlier.
>, and charges the ratio on the last Friday of the second preceding fortnight.
</p>
}
}
@@ -678,17 +642,13 @@
<thead>
<tr>
<th class="f1s-table__particulars">Particulars</th>
<!-- Each column is a fortnight close, with the date its ratios are charged on named
beneath it. The base used to be a column of its own, where its balances sat
beside the closes' and read as a third period the bank was reporting rather than
as the figures behind the requirement. -->
<!-- The first two columns are fortnight closes; the third is the base both ratios are
charged on. Dates alone made the third read as a third close, and a reviewer had
no way to see which column the required amounts below were computed from. -->
@for (col of statutory.columns; track col; let i = $index) {
<th class="f1s-table__num">
{{ col }}
<span class="f1s-table__colnote">fortnight close</span>
@if (statutory.baseColumns?.[i]) {
<span class="f1s-table__colbase">charged on {{ statutory.baseColumns[i] }}</span>
}
<span class="f1s-table__colnote">{{ i === 2 ? 'base for ratios' : 'fortnight close' }}</span>
</th>
}
</tr>
@@ -719,7 +679,7 @@
requirement hangs off those dates, so they are printed rather than inferred. -->
@if (line.baseDates?.length) {
<tr class="f1s-subnote">
<td>Base dates — the matching close one month earlier</td>
<td>Base dates — last Friday of the second preceding fortnight</td>
@for (d of line.baseDates; track $index) {
<td class="f1s-table__num">{{ d }}</td>
}
@@ -743,104 +703,6 @@
<div class="f1s-sign__line">AUTHORISED SIGNATORY</div>
</div>
</div>
<!--
═══ Day by day ═══
Both ratios are reckoned over the period, not on the two closes alone — a bank that held
nothing for a fortnight and topped up on the close has a form whose columns pass. This is
the position the compliance actually turns on, and the first thing an inspector asks for.
-->
@if (statutory.daily?.days?.length) {
<div class="f1s-daily">
<h3 class="f1s-daily__title">
Day by day — {{ statutory.daily!.from }} to {{ statutory.daily!.to }}
@if (statutory.daily!.crrShortDays || statutory.daily!.slrShortDays) {
<span class="f1s-daily__flag">
@if (statutory.daily!.crrShortDays) {
CRR short on {{ statutory.daily!.crrShortDays }} of {{ statutory.daily!.dayCount }} days.
}
@if (statutory.daily!.slrShortDays) {
SLR short on {{ statutory.daily!.slrShortDays }} of {{ statutory.daily!.dayCount }} days.
}
</span>
}
</h3>
<p class="f1s-daily__hint">
Each day is charged on its own base, which moves at the fortnight boundary. A shortfall shows as a negative.
</p>
<div class="f1s-daily__stats">
<div class="f1s-stat">
<span class="f1s-stat__label">CRR average held</span>
<span class="f1s-stat__value">{{ amount(statutory.daily!.crrAverage) }}</span>
<span class="f1s-stat__sub">
lowest {{ amount(statutory.daily!.crrLowest) }} on {{ statutory.daily!.crrLowestOn }}
</span>
</div>
<div class="f1s-stat">
<span class="f1s-stat__label">SLR average held</span>
<span class="f1s-stat__value">{{ amount(statutory.daily!.slrAverage) }}</span>
<span class="f1s-stat__sub">
lowest {{ amount(statutory.daily!.slrLowest) }} on {{ statutory.daily!.slrLowestOn }}
</span>
</div>
<div class="f1s-stat">
<span class="f1s-stat__label">Days counted</span>
<span class="f1s-stat__value">{{ statutory.daily!.dayCount }}</span>
<span class="f1s-stat__sub">to {{ statutory.daily!.to }}</span>
</div>
</div>
<table class="f1s-table f1s-daily__table">
<thead>
<tr>
<th rowspan="2" class="f1s-table__particulars">Date</th>
<!-- The liabilities, before the ratios. Two of them, because they are not the same
figure: the day's own move daily, the base holds still for the whole fortnight,
and it is the base the requirement is charged on. Without both, a table of
identical rows gives no way to see a day the bank's liabilities moved. -->
<th colspan="3" class="f1s-table__num">Net demand &amp; time liabilities</th>
<th colspan="3" class="f1s-table__num">Section 18 — CRR</th>
<th colspan="3" class="f1s-table__num">Section 24 — SLR</th>
</tr>
<tr>
<th class="f1s-table__num">On the day</th>
<th class="f1s-table__num">Charged on</th>
<th class="f1s-table__num">Base amount</th>
<th class="f1s-table__num">Required</th>
<th class="f1s-table__num">Maintained</th>
<th class="f1s-table__num">Surplus / (short)</th>
<th class="f1s-table__num">Required</th>
<th class="f1s-table__num">Maintained</th>
<th class="f1s-table__num">Surplus / (short)</th>
</tr>
</thead>
<tbody>
@for (day of statutory.daily!.days; track day.date) {
<tr [class.f1s-daily--short]="day.crrSurplus < 0 || day.slrSurplus < 0">
<td>{{ day.date }}</td>
<!-- A day the bank's own liabilities moved is marked, because that is the thing
this column exists to show and on thirty near-identical rows it is exactly
what the eye goes past. -->
<td class="f1s-table__num" [class.f1s-daily__moved]="dailyOwnMoved(day, $index)">
{{ day.ownLiabilities === undefined ? '—' : amount(day.ownLiabilities) }}
</td>
<td class="f1s-table__num f1s-daily__base">{{ day.baseDate }}</td>
<td class="f1s-table__num f1s-daily__base">
{{ day.baseLiabilities === undefined ? '—' : amount(day.baseLiabilities) }}
</td>
<td class="f1s-table__num">{{ amount(day.crrRequired) }}</td>
<td class="f1s-table__num">{{ amount(day.crrMaintained) }}</td>
<td class="f1s-table__num" [class.f1s-neg]="day.crrSurplus < 0">{{ amount(day.crrSurplus) }}</td>
<td class="f1s-table__num">{{ amount(day.slrRequired) }}</td>
<td class="f1s-table__num">{{ amount(day.slrMaintained) }}</td>
<td class="f1s-table__num" [class.f1s-neg]="day.slrSurplus < 0">{{ amount(day.slrSurplus) }}</td>
</tr>
}
</tbody>
</table>
</div>
}
}
}
@@ -969,18 +831,10 @@
<thead>
<tr>
<th class="f1s-table__particulars">Particulars</th>
<!-- A snapshot saved before the base became a note rather than a column still
carries three columns, so the heading is read from the snapshot itself: its
third column was the base, and saying so keeps an old saved return readable. -->
@for (col of saved.columns; track col; let i = $index) {
<th class="f1s-table__num">
{{ col }}
<span class="f1s-table__colnote">
{{ saved.columns.length > 2 && i === 2 ? 'base for ratios' : 'fortnight close' }}
</span>
@if (saved.baseColumns?.[i]) {
<span class="f1s-table__colbase">charged on {{ saved.baseColumns[i] }}</span>
}
<span class="f1s-table__colnote">{{ i === 2 ? 'base for ratios' : 'fortnight close' }}</span>
</th>
}
</tr>
@@ -92,6 +92,14 @@ const routes: Routes = [
(m) => m.InterestPostingReportComponent
)
},
{
path: 'fd-interest-posting',
data: { title: 'FD Interest Posting Reports', breadcrumb: 'FD Interest Posting Reports' },
loadComponent: () =>
import('../fd-interest-posting-report/fd-interest-posting-report.component').then(
(m) => m.FdInterestPostingReportComponent
)
},
{
path: 'dormancy',
data: { title: 'Inactive, Dormant & DEAF', breadcrumb: 'Inactive, Dormant & DEAF' },
@@ -0,0 +1,80 @@
<!--
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/.
-->
@if (active) {
@if (currentMode === 'overlay') {
<!-- Mifos Mobile Transaction / Action Progress Overlay -->
<div class="cbs-loading-backdrop" (click)="preventDismiss($event)">
<div class="cbs-loading-dialog" role="dialog" aria-modal="true" [attr.aria-label]="title || 'Loading'">
<div class="cbs-lottie-container" #lottieContainer>
<!-- Vector Fallback if Lottie is loading -->
@if (!lottieLoaded) {
<div class="cbs-vector-spinner">
<div class="spinner-ring ring-outer"></div>
<div class="spinner-ring ring-mid"></div>
<div class="spinner-ring ring-inner"></div>
</div>
}
</div>
<div class="cbs-loading-dialog__content">
<h3 class="cbs-loading-dialog__title">{{ title || 'CBS Nexora' }}</h3>
<p class="cbs-loading-dialog__message">{{ message || 'Please wait...' }}</p>
</div>
<div class="cbs-loading-pulse-bar">
<div class="cbs-pulse-bar__indicator"></div>
</div>
</div>
</div>
}
@if (currentMode === 'fullscreen') {
<!-- Full-Screen App Boot / Splash Screen -->
<div class="cbs-fullscreen-loader">
<div class="cbs-fullscreen-loader__brand">
<img src="assets/images/cbsnexora-logo.png" alt="CBS Nexora" class="cbs-fullscreen-loader__logo-img" />
<span class="cbs-brand-sub">Core Banking System</span>
</div>
<div class="cbs-lottie-container cbs-lottie-container--large" #lottieContainer>
@if (!lottieLoaded) {
<div class="cbs-vector-spinner cbs-vector-spinner--large">
<div class="spinner-ring ring-outer"></div>
<div class="spinner-ring ring-mid"></div>
<div class="spinner-ring ring-inner"></div>
</div>
}
</div>
<div class="cbs-progress-track">
<div class="cbs-progress-fill"></div>
</div>
<p class="cbs-fullscreen-loader__status">{{ message || 'Connecting to Core Banking Engine...' }}</p>
</div>
}
@if (currentMode === 'inline') {
<!-- Inline Component Loader -->
<div class="cbs-inline-loader">
<div class="cbs-lottie-container cbs-lottie-container--compact" #lottieContainer>
@if (!lottieLoaded) {
<div class="cbs-vector-spinner cbs-vector-spinner--compact">
<div class="spinner-ring ring-outer"></div>
<div class="spinner-ring ring-mid"></div>
<div class="spinner-ring ring-inner"></div>
</div>
}
</div>
@if (message) {
<p class="cbs-inline-loader__message">{{ message }}</p>
}
</div>
}
}
@@ -0,0 +1,349 @@
/**
* 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/.
*/
:host {
display: block;
}
/* ==========================================================================
Overlay Mode (MifosProgressIndicatorOverlay / MifosLoadingDialog)
========================================================================== */
.cbs-loading-backdrop {
position: fixed;
inset: 0;
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
background: rgb(9 13 22 / 72%);
backdrop-filter: blur(8px);
animation: backdrop-fade-in 0.25s ease-out forwards;
}
.cbs-loading-dialog {
position: relative;
width: 90%;
max-width: 340px;
padding: 28px 24px;
background: #111827;
color: #f8fafc;
border-radius: 20px;
border: 1px solid rgb(255 255 255 / 12%);
box-shadow:
0 25px 60px -15px rgb(0 0 0 / 65%),
0 0 35px rgb(37 99 235 / 18%);
text-align: center;
overflow: hidden;
animation: dialog-scale-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
:host-context([data-theme='light']) .cbs-loading-dialog,
:host-context(.light-theme) .cbs-loading-dialog {
background: #fff;
color: #0f172a;
border-color: rgb(0 0 0 / 8%);
box-shadow:
0 25px 50px -12px rgb(0 0 0 / 25%),
0 0 25px rgb(37 99 235 / 12%);
}
.cbs-loading-dialog__content {
margin-top: 10px;
margin-bottom: 16px;
}
.cbs-loading-dialog__title {
font-size: 1.05rem;
font-weight: 700;
letter-spacing: -0.01em;
margin: 0 0 6px;
}
.cbs-loading-dialog__message {
font-size: 0.82rem;
color: #94a3b8;
line-height: 1.45;
margin: 0;
}
:host-context([data-theme='light']) .cbs-loading-dialog__message,
:host-context(.light-theme) .cbs-loading-dialog__message {
color: #64748b;
}
/* Pulse bar at dialog bottom */
.cbs-loading-pulse-bar {
width: 100%;
height: 3px;
background: rgb(255 255 255 / 8%);
border-radius: 99px;
overflow: hidden;
position: relative;
margin-top: 6px;
}
.cbs-pulse-bar__indicator {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 40%;
background: linear-gradient(90deg, #2563eb, #38bdf8);
border-radius: 99px;
animation: pulse-bar-slide 1.4s infinite ease-in-out;
}
/* ==========================================================================
Full-Screen Mode (App Boot / Splash)
========================================================================== */
.cbs-fullscreen-loader {
position: fixed;
inset: 0;
z-index: 99999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: radial-gradient(circle at center, #0f172a 0%, #090d16 100%);
color: #f8fafc;
padding: 24px;
text-align: center;
}
:host-context([data-theme='light']) .cbs-fullscreen-loader,
:host-context(.light-theme) .cbs-fullscreen-loader {
background: radial-gradient(circle at center, #fff 0%, #f1f5f9 100%);
color: #0f172a;
}
.cbs-fullscreen-loader__brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.cbs-fullscreen-loader__logo-img {
height: 52px;
width: auto;
max-width: 260px;
object-fit: contain;
filter: drop-shadow(0 6px 18px rgb(0 0 0 / 45%));
}
.cbs-brand-icon {
width: 64px;
height: 64px;
border-radius: 16px;
background: linear-gradient(135deg, #1e3a8a 0%, #0284c7 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10px 24px rgb(14 165 233 / 35%);
margin-bottom: 6px;
}
.cbs-brand-name {
font-size: 1.5rem;
font-weight: 800;
letter-spacing: -0.02em;
margin: 0;
}
.cbs-brand-sub {
font-size: 0.8rem;
color: #94a3b8;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.cbs-progress-track {
width: 240px;
height: 5px;
background: rgb(255 255 255 / 10%);
border-radius: 99px;
overflow: hidden;
margin: 18px 0 12px;
position: relative;
}
.cbs-progress-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 45%;
background: linear-gradient(90deg, #2563eb, #38bdf8);
border-radius: 99px;
box-shadow: 0 0 12px rgb(56 189 248 / 50%);
animation: pulse-bar-slide 1.5s infinite ease-in-out;
}
.cbs-fullscreen-loader__status {
font-size: 0.82rem;
color: #94a3b8;
font-family: inherit;
margin: 0;
}
/* ==========================================================================
Inline Mode
========================================================================== */
.cbs-inline-loader {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
text-align: center;
}
.cbs-inline-loader__message {
font-size: 0.82rem;
color: #94a3b8;
margin-top: 10px;
}
/* ==========================================================================
Lottie & Vector Spinner Dimensions
========================================================================== */
.cbs-lottie-container {
width: 110px;
height: 110px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
.cbs-lottie-container--large {
width: 140px;
height: 140px;
}
.cbs-lottie-container--compact {
width: 64px;
height: 64px;
}
/* Vector Spinner Fallback (CSS Multi-Ring Loader) */
.cbs-vector-spinner {
width: 72px;
height: 72px;
position: relative;
}
.cbs-vector-spinner--large {
width: 96px;
height: 96px;
}
.cbs-vector-spinner--compact {
width: 44px;
height: 44px;
}
.spinner-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 3px solid transparent;
}
.ring-outer {
border-top-color: #2563eb;
border-right-color: #2563eb;
animation: spin-outer 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
}
.ring-mid {
inset: 6px;
border-bottom-color: #0ea5e9;
border-left-color: #0ea5e9;
animation: spin-mid 1.6s cubic-bezier(0.5, 0, 0.5, 1) infinite reverse;
}
.ring-inner {
inset: 13px;
border-top-color: #10b981;
animation: spin-inner 0.9s cubic-bezier(0.5, 0, 0.5, 1) infinite;
}
/* ==========================================================================
Keyframe Animations
========================================================================== */
@keyframes backdrop-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialog-scale-in {
from {
opacity: 0;
transform: scale(0.92);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes pulse-bar-slide {
0% {
left: -40%;
width: 30%;
}
50% {
left: 35%;
width: 50%;
}
100% {
left: 100%;
width: 30%;
}
}
@keyframes spin-outer {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes spin-mid {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes spin-inner {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,168 @@
/**
* 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 {
Component,
OnInit,
OnDestroy,
Input,
ElementRef,
ViewChild,
AfterViewInit,
inject,
ChangeDetectorRef
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Subscription } from 'rxjs';
import { LoadingScreenService, LoadingConfig } from './loading-screen.service';
declare const lottie: any;
@Component({
selector: 'mifosx-loading-screen',
standalone: true,
imports: [CommonModule],
templateUrl: './loading-screen.component.html',
styleUrls: ['./loading-screen.component.scss']
})
export class LoadingScreenComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly loadingService = inject(LoadingScreenService);
private readonly cdr = inject(ChangeDetectorRef);
@Input() mode: 'overlay' | 'inline' | 'fullscreen' = 'overlay';
@Input() title: string = 'CBS Nexora';
@Input() message: string = 'Processing transaction...';
@Input() active: boolean = false;
@Input() isGlobalOverlay: boolean = true;
@ViewChild('lottieContainer') lottieContainerRef?: ElementRef<HTMLDivElement>;
currentMode: 'overlay' | 'inline' | 'fullscreen' = 'overlay';
lottieLoaded: boolean = false;
private animInstance: any = null;
private subscription?: Subscription;
ngOnInit(): void {
this.currentMode = this.mode;
if (this.isGlobalOverlay) {
this.subscription = new Subscription();
this.subscription.add(
this.loadingService.isVisible$.subscribe((visible) => {
this.active = visible;
this.cdr.markForCheck();
if (visible) {
setTimeout(() => this.initLottie(), 50);
} else {
this.destroyLottie();
}
})
);
this.subscription.add(
this.loadingService.config$.subscribe((cfg: LoadingConfig) => {
if (cfg.title !== undefined) this.title = cfg.title;
if (cfg.message !== undefined) this.message = cfg.message;
if (cfg.mode !== undefined) this.currentMode = cfg.mode;
this.cdr.markForCheck();
})
);
}
}
ngAfterViewInit(): void {
if (this.active) {
this.initLottie();
}
}
ngOnDestroy(): void {
this.subscription?.unsubscribe();
this.destroyLottie();
}
preventDismiss(event: MouseEvent): void {
event.stopPropagation();
}
private initLottie(): void {
if (!this.lottieContainerRef?.nativeElement) return;
this.destroyLottie();
const container = this.lottieContainerRef.nativeElement;
// Check if lottie-web global is available
if (typeof lottie !== 'undefined') {
try {
this.animInstance = lottie.loadAnimation({
container,
renderer: 'svg',
loop: true,
autoplay: true,
path: 'assets/animations/loading_animation.json'
});
this.lottieLoaded = true;
this.cdr.markForCheck();
return;
} catch (err) {
console.warn('Could not load Lottie animation, using vector fallback', err);
}
}
// Try dynamic script load of local asset if not loaded yet
this.loadLottieScript()
.then(() => {
if (typeof lottie !== 'undefined' && this.lottieContainerRef?.nativeElement) {
this.animInstance = lottie.loadAnimation({
container: this.lottieContainerRef.nativeElement,
renderer: 'svg',
loop: true,
autoplay: true,
path: 'assets/animations/loading_animation.json'
});
this.lottieLoaded = true;
this.cdr.markForCheck();
}
})
.catch(() => {
this.lottieLoaded = false;
this.cdr.markForCheck();
});
}
private loadLottieScript(): Promise<void> {
return new Promise((resolve, reject) => {
if (typeof lottie !== 'undefined') {
resolve();
return;
}
const existingScript = document.getElementById('cbs-lottie-script');
if (existingScript) {
existingScript.addEventListener('load', () => resolve());
return;
}
const script = document.createElement('script');
script.id = 'cbs-lottie-script';
script.src = 'assets/js/lottie.min.js';
script.onload = () => resolve();
script.onerror = () => reject();
document.head.appendChild(script);
});
}
private destroyLottie(): void {
if (this.animInstance) {
try {
this.animInstance.destroy();
} catch (_) {}
this.animInstance = null;
}
}
}
@@ -0,0 +1,61 @@
/**
* 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 } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
export interface LoadingConfig {
title?: string;
message?: string;
mode?: 'overlay' | 'inline' | 'fullscreen';
}
@Injectable({
providedIn: 'root'
})
export class LoadingScreenService {
private readonly visibleSubject = new BehaviorSubject<boolean>(false);
private readonly configSubject = new BehaviorSubject<LoadingConfig>({
title: 'CBS Nexora',
message: 'Processing transaction...',
mode: 'overlay'
});
readonly isVisible$: Observable<boolean> = this.visibleSubject.asObservable();
readonly config$: Observable<LoadingConfig> = this.configSubject.asObservable();
/**
* Show the loading screen/overlay
* @param message Optional message to display (e.g. "Processing Transfer...")
* @param title Optional title to display (defaults to "CBS Nexora")
* @param mode Optional display mode ('overlay' | 'fullscreen')
*/
show(message?: string, title?: string, mode: 'overlay' | 'fullscreen' = 'overlay'): void {
this.configSubject.next({
title: title || 'CBS Nexora',
message: message || 'Please wait...',
mode
});
this.visibleSubject.next(true);
}
/**
* Hide the loading screen/overlay
*/
hide(): void {
this.visibleSubject.next(false);
}
/**
* Update message while loading screen is active
*/
setMessage(message: string): void {
const current = this.configSubject.getValue();
this.configSubject.next({ ...current, message });
}
}
+3
View File
@@ -30,6 +30,7 @@ import { SearchToolComponent } from './search-tool/search-tool.component';
import { KeyboardShortcutsDialogComponent } from './keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component';
import { ServerSelectorComponent } from './server-selector/server-selector.component';
import { TenantSelectorComponent } from './tenant-selector/tenant-selector.component';
import { LoadingScreenComponent } from './loading-screen/loading-screen.component';
/** Custom Modules */
import { IconsModule } from './icons.module';
@@ -116,9 +117,11 @@ import { DatetimePickerStubComponent } from './datetime-picker-stub/datetime-pic
DropdownComponent,
InputAmountComponent,
InputPasswordComponent,
LoadingScreenComponent,
DatetimePickerStubComponent // temporarily added stub for datetime picker
],
exports: [
LoadingScreenComponent,
FileUploadComponent,
FooterComponent,
LanguageSelectorComponent,
-8
View File
@@ -421,14 +421,6 @@ export class SystemService {
return this.http.get(`/businessdate/${dateType}`);
}
/**
* @returns {Observable<{ today: string }>} The bank's "today" (yyyy-MM-dd): the business date while
* enable-business-date is on, the server's date while it is off. Readable by any signed-in user.
*/
getBusinessToday(): Observable<{ today: string }> {
return this.http.get<{ today: string }>('/branchday/today');
}
/**
* @param {any} dateData Business Date data to be updated.
* @returns {Observable<any>}
@@ -1,138 +0,0 @@
/**
* @jest-environment-options {"customExportConditions": ["node", "node-addons"]}
*/
/**
* 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 { installTdsConfigFixture } from '../letter-mgmt/certificates/form-15gh/tds-config.fixture';
import { buildTdsInterestWorkbook } from './tds-interest-export';
import { TdsDeductee, setTdsDeductees } from './tds-reports-data';
import { tdsInterestRows, tdsInterestSummary } from './tds-reports.compute';
installTdsConfigFixture();
const FY = '2025-26';
const Q = ['2025-06-30', '2025-09-30', '2025-12-31', '2026-03-31'];
const BOOK: TdsDeductee[] = [
{
clientId: 1,
customerId: 'C1',
name: 'Ramesh',
pan: 'ABCPR1234K',
dob: '1980-05-01',
branchCode: 'Head Office',
declarations: {},
accounts: [
{
id: 11,
accountNo: 'FD11',
depositTypeCode: 'depositAccountType.fixedDeposit',
clientId: 1,
credits: Q.map((date) => ({ date, amount: 15_000 })),
deductions: [{ date: Q[3], amount: 6_000 }]
},
{
id: 12,
accountNo: 'SB12',
depositTypeCode: 'depositAccountType.savingsDeposit',
clientId: 1,
credits: [{ date: Q[1], amount: 1_200 }],
deductions: []
}
]
},
{
clientId: 4,
customerId: 'C4',
name: 'Kamala',
pan: 'ABCPK9012M',
dob: '1979-07-07',
branchCode: 'bhopal',
declarations: { [FY]: { kind: '15G', acceptedOn: '2025-10-15', fy: FY, uin: 'UIN0001' } },
accounts: [
{
id: 41,
accountNo: 'FD41',
depositTypeCode: 'depositAccountType.fixedDeposit',
clientId: 4,
credits: Q.map((date) => ({ date, amount: 20_000 })),
deductions: []
}
]
}
];
function build() {
setTdsDeductees(BOOK);
const filter = {
fy: FY,
branchCode: '',
quarter: 0 as const,
band: '' as const,
search: '',
crossedOnly: false,
nearThresholdOnly: false
};
const rows = tdsInterestRows(filter);
return buildTdsInterestWorkbook({
fy: FY,
scopeLabel: 'All branches',
rules: { general: 50_000, senior: 100_000, rate: 10, rateNoPan: 20 },
statement: 'Form 26Q',
rows,
declaredRows: rows.filter((r) => r.hasDeclaration),
summary: tdsInterestSummary(filter),
statusOf: () => ({ label: 'status' })
});
}
describe('TDS register workbook', () => {
it('has the four sheets a reader expects, in order', () => {
expect(build().worksheets.map((w) => w.name)).toEqual(['Summary', 'Customers', 'Accounts', 'Form 121 given']);
});
it('lists one row per customer, with the figures as numbers and a live total', () => {
const ws = build().getWorksheet('Customers')!;
expect(ws.getRow(3).getCell(2).value).toBe('Customer name');
const at = [4, 5].find((n) => ws.getRow(n).getCell(2).value === 'Ramesh')!;
const ramesh = ws.getRow(at);
expect(ramesh.getCell(2).value).toBe('Ramesh');
expect(ramesh.getCell(7).value).toBe(60_000);
expect(ramesh.getCell(7).numFmt).toContain('#,##0.00');
expect(ramesh.getCell(11).value).toBe(6_000);
expect(ramesh.getCell(12).value).toBe(6_000);
expect(ramesh.getCell(13).value).toEqual({ formula: `K${at}-L${at}` });
const total = ws.getRow(6);
expect(total.getCell(2).value).toBe('Total');
expect(total.getCell(7).value).toEqual({ formula: 'SUM(G4:G5)' });
});
it('lists every account behind the figures, saying which ones count', () => {
const ws = build().getWorksheet('Accounts')!;
const byAccount = new Map([4, 5, 6].map((n) => [ws.getRow(n).getCell(4).value, ws.getRow(n)]));
expect([...byAccount.keys()].sort()).toEqual(['FD11', 'FD41', 'SB12']);
expect(byAccount.get('SB12')!.getCell(6).value).toBe('No');
expect(byAccount.get('FD11')!.getCell(6).value).toBe('Yes');
expect(byAccount.get('FD11')!.getCell(10).value).toBeInstanceOf(Date);
});
it('puts the declarants on their own sheet with the TDS the form saved', () => {
const ws = build().getWorksheet('Form 121 given')!;
const kamala = ws.getRow(4);
expect(kamala.getCell(2).value).toBe('Kamala');
expect(kamala.getCell(7).value).toBe('UIN0001');
expect(kamala.getCell(10).value).toBe(40_000);
expect(kamala.getCell(11).value).toBe(4_000);
});
it('writes a file Excel can open', async () => {
const buffer = await build().xlsx.writeBuffer();
expect(buffer.byteLength).toBeGreaterThan(5_000);
});
});
-379
View File
@@ -1,379 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import * as ExcelJS from 'exceljs';
import { TdsAccountLine, TdsInterestRow, TdsInterestSummary } from './tds-reports.compute';
/** What the workbook needs from the screen, so it prints exactly what the user is looking at. */
export interface TdsExportInput {
fy: string;
scopeLabel: string;
/** The financial-year rules, from Organization › TDS Configuration. */
rules: { general: number; senior: number; rate: number; rateNoPan: number } | null;
/** The quarterly return the declarants' gross-paid interest is reported in. */
statement: string;
rows: TdsInterestRow[];
declaredRows: TdsInterestRow[];
summary: TdsInterestSummary;
statusOf: (row: TdsInterestRow) => { label: string };
}
const MONEY = '"₹"#,##0.00';
const DATE = 'dd-mmm-yyyy';
const NAVY = 'FF1F4E79';
const STRIPE = 'FFF3F6FA';
const LINE = 'FFD0D7E2';
const GREEN = 'FF15803D';
const AMBER = 'FFB45309';
const ACCOUNT_TYPE: Record<TdsAccountLine['type'], string> = {
FD: 'Fixed deposit',
RD: 'Recurring deposit',
SB: 'Savings',
OTHER: 'Other'
};
/** A column: its heading, width and, for figures, the cell format. */
interface Col {
header: string;
width: number;
fmt?: string;
/** Totalled in the closing row. */
sum?: boolean;
}
/**
* The Deposit Interest & TDS register as a workbook a branch can read without the screen beside it:
* a one-page summary, one row per customer, one row per account, and the declarants on their own.
*/
export function buildTdsInterestWorkbook(input: TdsExportInput): ExcelJS.Workbook {
const wb = new ExcelJS.Workbook();
wb.creator = 'CBS Nexora';
wb.created = new Date();
summarySheet(wb, input);
customersSheet(wb, input);
accountsSheet(wb, input);
declarationsSheet(wb, input);
return wb;
}
// ─────────────────────────────── Sheets ───────────────────────────────
function summarySheet(wb: ExcelJS.Workbook, input: TdsExportInput): void {
const ws = wb.addWorksheet('Summary', { views: [{ showGridLines: false }] });
ws.columns = [
{ width: 46 },
{ width: 22 }
];
title(ws, `Deposit Interest & TDS — FY ${input.fy}`, 2);
ws.addRow([`Branch: ${input.scopeLabel}`]);
ws.addRow([`Prepared on: ${new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' })}`]);
ws.getRow(2).font = { color: { argb: 'FF555555' } };
ws.getRow(3).font = { color: { argb: 'FF555555' } };
ws.addRow([]);
const s = input.summary;
section(ws, 'Key figures');
const figures: [string, number, string?][] = [
['Customers in this report', s.deductees],
[' of whom have an FD / RD being tracked', s.trackedCustomers],
['FD / RD interest received this year', s.applicableInterest, MONEY],
['Savings interest (shown only — no TDS)', s.exemptInterest, MONEY],
['Customers over the TDS limit', s.crossed],
['TDS due by the rules', s.totalTds, MONEY],
['TDS actually deducted from accounts', s.actualTds, MONEY],
['Difference (due − deducted) — should be 0', s.totalTds - s.actualTds, MONEY],
['Customers with Form 121 / 15G / 15H', s.declarations],
['TDS not deducted because of those forms', s.tdsSaved, MONEY],
['Interest paid without TDS (report in the return)', s.paidWithoutDeduction, MONEY]
];
for (const [label, value, fmt] of figures) {
const r = ws.addRow([label, value]);
r.getCell(2).numFmt = fmt ?? '#,##0';
r.getCell(2).alignment = { horizontal: 'right' };
boxRow(r, 2);
}
// The control figure stands out when it is not zero.
const diffRow = ws.getRow(ws.rowCount - 3);
if (Math.round(s.totalTds - s.actualTds) !== 0) {
diffRow.font = { bold: true, color: { argb: AMBER } };
}
ws.addRow([]);
if (input.rules) {
section(ws, 'Rules for this year');
const rules: [string, string][] = [
['TDS limit — below 60', rupees(input.rules.general)],
['TDS limit — senior citizen (60+)', rupees(input.rules.senior)],
['Rate with PAN', `${input.rules.rate}%`],
['Rate without PAN', `${input.rules.rateNoPan}%`]
];
for (const [label, value] of rules) {
const r = ws.addRow([label, value]);
r.getCell(2).alignment = { horizontal: 'right' };
boxRow(r, 2);
}
ws.addRow([]);
}
section(ws, 'How to read this workbook');
const notes = [
'Only interest from fixed and recurring deposits counts toward the limit — on the day it reaches savings, or when paid out at closure.',
"A savings account's own interest is shown for information and never attracts TDS.",
"Once a customer's interest for the year crosses the limit, TDS is due on the whole year's interest, not only the excess.",
'A Form 121 / 15G / 15H stops TDS on interest received after the form was accepted.',
'Sheet "Customers": one row per customer. Sheet "Accounts": every account behind those figures.',
'Sheet "Form 121 given": customers with a declaration, and the TDS the form saved them.'
];
for (const n of notes) {
const r = ws.addRow([`• ${n}`]);
ws.mergeCells(r.number, 1, r.number, 2);
r.alignment = { wrapText: true, vertical: 'top' };
r.height = 30;
}
}
function customersSheet(wb: ExcelJS.Workbook, input: TdsExportInput): void {
const cols: Col[] = [
{ header: 'S.No', width: 6 },
{ header: 'Customer name', width: 26 },
{ header: 'Customer ID', width: 13 },
{ header: 'PAN', width: 13 },
{ header: 'Branch', width: 14 },
{ header: 'Age group', width: 12 },
{ header: 'FD/RD interest received (₹)', width: 16, fmt: MONEY, sum: true },
{ header: 'TDS limit (₹)', width: 13, fmt: MONEY },
{ header: 'Over the limit?', width: 10 },
{ header: 'Rate', width: 7 },
{ header: 'TDS due (₹)', width: 13, fmt: MONEY, sum: true },
{ header: 'TDS deducted (₹)', width: 13, fmt: MONEY, sum: true },
{ header: 'Difference (₹)', width: 13, fmt: MONEY, sum: true },
{ header: 'Form 121 given?', width: 10 },
{ header: 'Status', width: 42 }
];
const ws = table(wb, 'Customers', `Customers — FY ${input.fy} · ${input.scopeLabel}`, cols);
input.rows.forEach((r, i) => {
const line = ws.addRow([
i + 1,
r.deductee.name,
r.deductee.customerId,
r.deductee.pan || 'Not on record',
r.branchCode,
r.band === 'SENIOR' ? 'Senior (60+)' : 'Below 60',
r.tdsApplicableInterest,
r.threshold,
r.crossed ? 'Yes' : 'No',
`${Math.round(r.rateApplied * 100)}%`,
r.schedule.totalTds,
r.actualTds,
null,
r.hasDeclaration ? 'Yes' : 'No',
input.statusOf(r).label
]);
// Due − deducted as a formula, so an edited figure keeps the check honest.
line.getCell(13).value = { formula: `K${line.number}-L${line.number}` };
styleDataRow(line, cols, i);
if (!r.deductee.pan) {
line.getCell(4).font = { color: { argb: AMBER } };
}
if (r.crossed) {
line.getCell(9).font = { bold: true, color: { argb: AMBER } };
}
if (Math.round(r.schedule.totalTds - r.actualTds) !== 0) {
line.getCell(13).font = { bold: true, color: { argb: AMBER } };
}
});
totals(ws, cols, input.rows.length);
}
function accountsSheet(wb: ExcelJS.Workbook, input: TdsExportInput): void {
const cols: Col[] = [
{ header: 'S.No', width: 6 },
{ header: 'Customer name', width: 26 },
{ header: 'Customer ID', width: 13 },
{ header: 'Account no.', width: 19 },
{ header: 'Account type', width: 17 },
{ header: 'Counted for TDS?', width: 10 },
{ header: 'Why', width: 46 },
{ header: 'Interest this year (₹)', width: 15, fmt: MONEY, sum: true },
{ header: 'Credits', width: 8 },
{ header: 'Last credited', width: 13, fmt: DATE },
{ header: 'TDS deducted (₹)', width: 13, fmt: MONEY, sum: true },
{ header: 'Account held by', width: 22 }
];
const ws = table(wb, 'Accounts', `Accounts — FY ${input.fy} · ${input.scopeLabel}`, cols);
let n = 0;
for (const r of input.rows) {
for (const a of r.accounts) {
const line = ws.addRow([
n + 1,
r.deductee.name,
r.deductee.customerId,
a.accountNo,
ACCOUNT_TYPE[a.type],
a.tracked ? 'Yes' : 'No',
a.reason,
a.interest,
a.postings,
a.lastPostedOn ? isoToDate(a.lastPostedOn) : '—',
a.tdsDeducted,
a.holderName ?? 'Same customer'
]);
styleDataRow(line, cols, n);
if (a.tracked) {
line.getCell(6).font = { bold: true, color: { argb: GREEN } };
}
n++;
}
}
totals(ws, cols, n);
}
function declarationsSheet(wb: ExcelJS.Workbook, input: TdsExportInput): void {
const cols: Col[] = [
{ header: 'S.No', width: 6 },
{ header: 'Customer name', width: 26 },
{ header: 'Customer ID', width: 13 },
{ header: 'PAN', width: 13 },
{ header: 'Branch', width: 14 },
{ header: 'Form', width: 10 },
{ header: 'UIN', width: 18 },
{ header: 'Accepted on', width: 13, fmt: DATE },
{ header: 'FD/RD interest received (₹)', width: 16, fmt: MONEY, sum: true },
{ header: `Paid without TDS — report in ${input.statement} (₹)`, width: 20, fmt: MONEY, sum: true },
{ header: 'TDS not deducted because of the form (₹)', width: 18, fmt: MONEY, sum: true },
{ header: 'TDS deducted before the form (₹)', width: 16, fmt: MONEY, sum: true }
];
const ws = table(wb, 'Form 121 given', `Customers with Form 121 / 15G / 15H — FY ${input.fy} · ${input.scopeLabel}`, cols);
input.declaredRows.forEach((r, i) => {
const line = ws.addRow([
i + 1,
r.deductee.name,
r.deductee.customerId,
r.deductee.pan || 'Not on record',
r.branchCode,
`Form ${r.form}`,
r.declarationUin ?? 'Not allotted',
r.declarationAcceptedOn ? isoToDate(r.declarationAcceptedOn) : '—',
r.tdsApplicableInterest,
r.paidWithoutDeduction,
r.tdsSaved,
r.actualTds
]);
styleDataRow(line, cols, i);
if (!r.declarationUin) {
line.getCell(7).font = { color: { argb: AMBER } };
}
});
if (input.declaredRows.length === 0) {
const r = ws.addRow(['', 'No customer has a declaration on record for this year.']);
r.font = { italic: true, color: { argb: 'FF777777' } };
} else {
totals(ws, cols, input.declaredRows.length);
}
}
// ─────────────────────────────── Styling ───────────────────────────────
/** A sheet with a title, a styled header row, frozen headings and a filter. */
function table(wb: ExcelJS.Workbook, name: string, heading: string, cols: Col[]): ExcelJS.Worksheet {
const ws = wb.addWorksheet(name, { views: [{ state: 'frozen', ySplit: 3, showGridLines: false }] });
ws.columns = cols.map((c) => ({ width: c.width }));
title(ws, heading, cols.length);
ws.addRow([]);
const head = ws.addRow(cols.map((c) => c.header));
head.height = 34;
head.eachCell((cell) => {
cell.font = { bold: true, color: { argb: 'FFFFFFFF' } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: NAVY } };
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
cell.border = border();
});
ws.autoFilter = { from: { row: 3, column: 1 }, to: { row: 3, column: cols.length } };
return ws;
}
function title(ws: ExcelJS.Worksheet, text: string, span: number): void {
const r = ws.addRow([text]);
ws.mergeCells(r.number, 1, r.number, span);
r.font = { bold: true, size: 14, color: { argb: NAVY } };
r.height = 24;
}
function section(ws: ExcelJS.Worksheet, text: string): void {
const r = ws.addRow([text]);
r.font = { bold: true, color: { argb: 'FFFFFFFF' } };
r.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: NAVY } };
r.getCell(2).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: NAVY } };
}
function styleDataRow(row: ExcelJS.Row, cols: Col[], index: number): void {
cols.forEach((c, i) => {
const cell = row.getCell(i + 1);
cell.border = border();
cell.alignment = { vertical: 'middle', wrapText: i === cols.length - 1 || c.width > 30 };
if (c.fmt) {
cell.numFmt = c.fmt;
cell.alignment = { ...cell.alignment, horizontal: 'right' };
}
if (index % 2 === 1) {
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: STRIPE } };
}
});
}
/** A closing row that totals the money columns with live SUM formulas. */
function totals(ws: ExcelJS.Worksheet, cols: Col[], count: number): void {
if (count === 0) {
return;
}
const first = 4;
const last = first + count - 1;
const r = ws.addRow(cols.map((c, i) => (i === 1 ? 'Total' : null)));
cols.forEach((c, i) => {
const cell = r.getCell(i + 1);
if (c.sum) {
const letter = ws.getColumn(i + 1).letter;
cell.value = { formula: `SUM(${letter}${first}:${letter}${last})` };
cell.numFmt = c.fmt ?? '#,##0';
cell.alignment = { horizontal: 'right' };
}
cell.font = { bold: true };
cell.border = { top: { style: 'medium', color: { argb: NAVY } }, bottom: { style: 'thin', color: { argb: LINE } } };
});
}
function boxRow(row: ExcelJS.Row, span: number): void {
for (let i = 1; i <= span; i++) {
row.getCell(i).border = border();
}
}
function border(): Partial<ExcelJS.Borders> {
const side = { style: 'thin' as const, color: { argb: LINE } };
return { top: side, left: side, bottom: side, right: side };
}
function rupees(n: number): string {
return `₹${Math.round(n).toLocaleString('en-IN')}`;
}
/** '2026-10-05' as a real date, so Excel sorts and filters it as one. */
function isoToDate(iso: string): Date {
const [
y,
m,
d
] = iso.slice(0, 10).split('-').map(Number);
return new Date(Date.UTC(y, m - 1, d));
}
@@ -49,11 +49,8 @@
account is which, and why.
</li>
<li>
Only the interest from <strong>fixed and recurring deposits that reaches the customer's savings account</strong>
is tracked — counted on the day it arrives. The savings account's own interest is shown for information but
never attracts TDS. Interest still inside an FD/RD counts when it is paid out at maturity or closure — to
savings, in cash or to a GL (then the TDS comes out of the payout). A full reinvestment into a new FD/RD is not
taxed.
Only <strong>fixed and recurring deposits</strong> are tracked. Savings interest is shown for information but
never attracts TDS.
</li>
@if (rules) {
<li>
@@ -67,14 +64,10 @@
record.
</li>
}
<li>A customer who has submitted Form 15G/15H is not deducted, even over the limit.</li>
<li>
A customer who has submitted Form 15G/15H is not deducted on interest received after the form was accepted.
</li>
<li>
TDS is <strong>debited from the savings account</strong> at the moment the FD/RD interest that takes the
customer over the limit arrives, and credited to the TDS payable account. The FD/RD itself is never debited.
The <strong>TDS</strong> column shows what is due by these rules and what was actually deducted — the two
should match.
The TDS figures here are <strong>worked out by this report</strong> from the interest actually posted. They
are not debited from the customer's account automatically.
</li>
</ol>
</div>
@@ -88,7 +81,7 @@
</div>
<div class="tdsr__kpi">
<div class="tdsr__kpi-n">₹{{ summary.applicableInterest | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">FD / RD interest received in savings — counts toward the limit</div>
<div class="tdsr__kpi-l">FD / RD interest — counts toward the limit</div>
</div>
<div class="tdsr__kpi tdsr__kpi--muted">
<div class="tdsr__kpi-n">₹{{ summary.exemptInterest | number: '1.0-0' }}</div>
@@ -100,17 +93,7 @@
</div>
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">₹{{ summary.totalTds | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">TDS due by the rules</div>
</div>
<div class="tdsr__kpi" [class.tdsr__kpi--ok]="summary.actualTds === summary.totalTds"
[class.tdsr__kpi--warn]="summary.actualTds !== summary.totalTds">
<div class="tdsr__kpi-n">₹{{ summary.actualTds | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">
Actually deducted from accounts
@if (summary.actualTds !== summary.totalTds) {
— ₹{{ summary.totalTds - summary.actualTds | number: '1.0-0' }} apart
}
</div>
<div class="tdsr__kpi-l">TDS to be deducted</div>
</div>
</div>
@@ -211,15 +194,7 @@
[class.tdsr__tab--active]="selectedTab === 'paying'"
(click)="selectTab('paying')"
>
TDS deducted / due ({{ payingRows.length }})
</button>
<button
type="button"
class="tdsr__tab"
[class.tdsr__tab--active]="selectedTab === 'declared'"
(click)="selectTab('declared')"
>
Form 121 / 15G / 15H given ({{ declaredRows.length }})
TDS to deduct ({{ payingRows.length }})
</button>
<button
type="button"
@@ -231,18 +206,6 @@
</button>
</div>
@if (selectedTab === 'declared') {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>
Customers who gave a declaration for FY {{ filter.fy }}. <strong>TDS not deducted</strong> is the tax that would
have been taken without the form; <strong>Paid without TDS</strong> is the interest received after the form was
accepted — it must still be reported in the {{ statute.statement }} return. Interest received before the form
stays taxable, and any TDS taken on it is shown as <strong>deducted before the form</strong>.
</div>
</div>
}
<!-- Table -->
<div class="tdsr__card">
<div class="tdsr__table-wrap">
@@ -251,19 +214,11 @@
<tr>
<th>Customer</th>
<th>Branch</th>
@if (selectedTab === 'declared') {
<th>Form · UIN · accepted on</th>
<th class="num">FD / RD interest received · paid without TDS</th>
<th class="num">Limit</th>
<th>Status</th>
<th class="num">TDS not deducted · deducted before the form</th>
} @else {
<th>Accounts</th>
<th class="num">FD / RD interest received this year</th>
<th class="num">Limit</th>
<th>Status</th>
<th class="num">TDS</th>
}
<th>Accounts</th>
<th class="num">FD / RD interest this year</th>
<th class="num">Limit</th>
<th>Status</th>
<th class="num">TDS</th>
<th class="col-actions"></th>
</tr>
</thead>
@@ -285,32 +240,20 @@
</div>
</td>
<td class="small">{{ row.branchCode || '—' }}</td>
@if (selectedTab === 'declared') {
<td class="small">
<span class="tdsr__pill tdsr__pill--ok">Form {{ row.form }}</span>
<div class="mono muted">{{ row.declarationUin || 'no UIN yet' }}</div>
<div class="muted">accepted {{ row.declarationAcceptedOn | date: 'd MMM y' }}</div>
</td>
<td class="num">
<strong>₹{{ row.tdsApplicableInterest | number: '1.0-0' }}</strong>
<div class="small tdsr__ok">₹{{ row.paidWithoutDeduction | number: '1.0-0' }} paid without TDS</div>
</td>
} @else {
<td class="small">
@if (row.trackedAccounts) {
<span class="tdsr__pill tdsr__pill--ok">{{ row.trackedAccounts }} tracked</span>
}
@if (untrackedOf(row).length) {
<span class="tdsr__pill">{{ untrackedOf(row).length }} not tracked</span>
}
</td>
<td class="num">
<strong>₹{{ row.tdsApplicableInterest | number: '1.0-0' }}</strong>
@if (row.exemptInterest) {
<div class="small muted">+ ₹{{ row.exemptInterest | number: '1.0-0' }} savings (no TDS)</div>
}
</td>
}
<td class="small">
@if (row.trackedAccounts) {
<span class="tdsr__pill tdsr__pill--ok">{{ row.trackedAccounts }} tracked</span>
}
@if (untrackedOf(row).length) {
<span class="tdsr__pill">{{ untrackedOf(row).length }} not tracked</span>
}
</td>
<td class="num">
<strong>₹{{ row.tdsApplicableInterest | number: '1.0-0' }}</strong>
@if (row.exemptInterest) {
<div class="small muted">+ ₹{{ row.exemptInterest | number: '1.0-0' }} savings (no TDS)</div>
}
</td>
<td class="num small muted">₹{{ row.threshold | number: '1.0-0' }}</td>
<td class="small">
<span
@@ -323,19 +266,9 @@
</span>
</td>
<td class="num">
@if (selectedTab === 'declared') {
<strong class="tdsr__ok">₹{{ row.tdsSaved | number: '1.0-0' }}</strong>
<div class="small muted">not deducted</div>
@if (row.actualTds || row.schedule.totalTds) {
<div class="small">₹{{ row.actualTds | number: '1.0-0' }} deducted before the form</div>
}
} @else if (row.schedule.totalTds || row.actualTds) {
@if (row.schedule.totalTds) {
<strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong>
<div class="small muted">due @ {{ row.rateApplied * 100 | number: '1.0-0' }}%</div>
<div class="small" [class.tdsr__ok]="row.actualTds === row.schedule.totalTds"
[class.tdsr__warn-text]="row.actualTds !== row.schedule.totalTds">
₹{{ row.actualTds | number: '1.0-0' }} deducted
</div>
<div class="small muted">@ {{ row.rateApplied * 100 | number: '1.0-0' }}%</div>
} @else if (row.tdsSaved) {
<span class="small tdsr__ok">₹{{ row.tdsSaved | number: '1.0-0' }} saved</span>
} @else {
@@ -365,21 +298,15 @@
<th>Account no.</th>
<th>Type</th>
<th>Tracked for TDS?</th>
<th class="num">Interest this year (FD/RD: received in savings)</th>
<th class="num">Credits</th>
<th>Last credited</th>
<th class="num">TDS deducted</th>
<th class="num">Interest this year</th>
<th class="num">Postings</th>
<th>Last posted</th>
</tr>
</thead>
<tbody>
@for (a of row.accounts; track a.accountNo) {
<tr>
<td class="mono small">
{{ a.accountNo }}
@if (a.holderName) {
<div class="muted">held by {{ a.holderName }}</div>
}
</td>
<td class="mono small">{{ a.accountNo }}</td>
<td class="small">{{ accountTypeLabel[a.type] }}</td>
<td class="small">
@if (a.tracked) {
@@ -392,14 +319,7 @@
<td class="num">₹{{ a.interest | number: '1.2-2' }}</td>
<td class="num small">{{ a.postings }}</td>
<td class="small muted">
{{ a.lastPostedOn ? (a.lastPostedOn | date: 'd MMM y') : 'Nothing received yet' }}
</td>
<td class="num">
@if (a.tdsDeducted) {
₹{{ a.tdsDeducted | number: '1.2-2' }}
} @else {
<span class="muted">—</span>
}
{{ a.lastPostedOn ? (a.lastPostedOn | date: 'd MMM y') : 'No interest posted yet' }}
</td>
</tr>
}
@@ -498,9 +418,6 @@
Loading…
} @else if (selectedTab === 'paying') {
No customer needs TDS deducted for these filters. Check the "No TDS" tab.
} @else if (selectedTab === 'declared') {
No customer has a Form 121 / 15G / 15H on record for this year. Record one from the customer's
Form 121 tab.
} @else {
No customers match these filters.
}
@@ -10,11 +10,11 @@ import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import * as ExcelJS from 'exceljs';
import { BranchScopeService } from '../core/branch-scope.service';
import { FY_TAX_CONFIG, Quarter, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { TdsDeductee, setTdsDeductees, tdsFys } from './tds-reports-data';
import { buildTdsInterestWorkbook } from './tds-interest-export';
import { TdsReportsService } from './tds-reports.service';
import {
NEAR_THRESHOLD_BAND,
@@ -26,9 +26,6 @@ import {
tdsInterestSummary
} from './tds-reports.compute';
/** The three answers the register gives: TDS taken, not taken because of a declaration, none due. */
type TdsTab = 'paying' | 'declared' | 'not-paying';
@Component({
selector: 'mifosx-tds-interest-report',
templateUrl: './tds-interest-report.component.html',
@@ -73,7 +70,7 @@ export class TdsInterestReportComponent implements OnInit {
/** Which customer's quarter-wise detail is open. */
expandedId: string | null = null;
exporting = false;
selectedTab: TdsTab = 'paying';
selectedTab: 'paying' | 'not-paying' = 'paying';
/** Rows per page; 0 shows the whole list. */
readonly pageSizeOptions = [
@@ -95,7 +92,6 @@ export class TdsInterestReportComponent implements OnInit {
private allRows: TdsInterestRow[] = [];
private paying: TdsInterestRow[] = [];
private notPaying: TdsInterestRow[] = [];
private declared: TdsInterestRow[] = [];
private summaryCacheKey = '';
private cachedSummary: TdsInterestSummary | null = null;
@@ -223,28 +219,8 @@ export class TdsInterestReportComponent implements OnInit {
if (!row.trackedAccounts) {
return { label: 'Not tracked — no FD/RD', tone: 'muted' };
}
if (row.hasDeclaration) {
const beforeForm = Math.round(row.schedule.totalTds - row.actualTds);
if (beforeForm > 0) {
return { label: `Form ${row.form} — ₹${beforeForm.toLocaleString('en-IN')} due from before the form, not yet deducted`, tone: 'warn' };
}
if (row.tdsSaved > 0) {
return { label: `Form ${row.form} — TDS not deducted`, tone: 'ok' };
}
return { label: `Form ${row.form} on record — below the limit so far`, tone: 'ok' };
}
if (row.schedule.totalTds > 0) {
const gap = Math.round(row.schedule.totalTds - row.actualTds);
if (gap > 0) {
return { label: `Over the limit — ₹${gap.toLocaleString('en-IN')} not yet deducted`, tone: 'warn' };
}
if (gap < 0) {
return { label: `Deducted ₹${(-gap).toLocaleString('en-IN')} more than due — check`, tone: 'warn' };
}
return { label: 'Over the limit — TDS deducted', tone: 'ok' };
}
if (row.actualTds > 0) {
return { label: `₹${Math.round(row.actualTds).toLocaleString('en-IN')} deducted but none due — check`, tone: 'warn' };
return { label: 'Over the limit — TDS due', tone: 'warn' };
}
if (row.hasDeclaration && row.crossed) {
return { label: 'Over the limit — 15G/15H, no TDS', tone: 'ok' };
@@ -271,12 +247,8 @@ export class TdsInterestReportComponent implements OnInit {
}
this.rowsCacheKey = key;
this.allRows = tdsInterestRows(this.filter);
// Three plain answers: whose TDS was taken, whose was not because they gave a declaration, and who
// owed none. A declarant whose tax was partly taken before the form is shown with the declarants,
// where both figures sit side by side.
this.declared = this.allRows.filter((r) => r.hasDeclaration);
this.paying = this.allRows.filter((r) => !r.hasDeclaration && (r.schedule.totalTds > 0 || r.actualTds > 0));
this.notPaying = this.allRows.filter((r) => !r.hasDeclaration && r.schedule.totalTds === 0 && r.actualTds === 0);
this.paying = this.allRows.filter((r) => r.schedule.totalTds > 0);
this.notPaying = this.allRows.filter((r) => r.schedule.totalTds === 0);
}
get rows(): TdsInterestRow[] {
@@ -294,11 +266,6 @@ export class TdsInterestReportComponent implements OnInit {
return this.notPaying;
}
get declaredRows(): TdsInterestRow[] {
this.ensureRows();
return this.declared;
}
get summary(): TdsInterestSummary {
const key = JSON.stringify(this.filter);
if (key !== this.summaryCacheKey || !this.cachedSummary) {
@@ -310,9 +277,6 @@ export class TdsInterestReportComponent implements OnInit {
/** Every row of the tab on screen, before paging. */
get visibleRows(): TdsInterestRow[] {
if (this.selectedTab === 'declared') {
return this.declaredRows;
}
return this.selectedTab === 'paying' ? this.payingRows : this.notPayingRows;
}
@@ -373,7 +337,7 @@ export class TdsInterestReportComponent implements OnInit {
this.expandedId = null;
}
selectTab(tab: TdsTab): void {
selectTab(tab: 'paying' | 'not-paying'): void {
this.selectedTab = tab;
this.onFilterChanged();
}
@@ -421,17 +385,105 @@ export class TdsInterestReportComponent implements OnInit {
async exportWorkbook(): Promise<void> {
this.exporting = true;
try {
const wb = buildTdsInterestWorkbook({
fy: this.filter.fy,
scopeLabel: this.scopeLabel,
rules: this.rules,
statement: this.statute.statement,
// What the screen shows under the current filters, every tab included.
rows: this.rows,
declaredRows: this.declaredRows,
summary: this.summary,
statusOf: (row) => this.statusOf(row)
});
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet(`TDS Interest FY ${this.filter.fy}`);
ws.addRow([`Deposit Interest & TDS — FY ${this.filter.fy}`]);
ws.addRow([`Scope: ${this.scopeLabel}`]);
ws.addRow([]);
ws.addRow([
'Customer',
'CIF',
'PAN',
'Branch',
'Accounts tracked for TDS',
'Accounts not tracked',
'Status',
'Form',
'Band',
'Age',
'Q1',
'Q2',
'Q3',
'Q4',
'Interest (FD/RD)',
'Savings interest (exempt)',
'Threshold',
'Crossed in',
'Rate %',
'TDS payable',
'Declaration',
'UIN',
'TDS saved',
'Paid without deduction'
]);
for (const r of this.rows) {
ws.addRow([
r.deductee.name,
r.deductee.customerId,
r.deductee.pan || 'NOT ON RECORD',
r.branchCode,
this.trackedOf(r)
.map((a) => `${a.accountNo} (${a.type})`)
.join(', '),
this.untrackedOf(r)
.map((a) => `${a.accountNo} (${a.type})`)
.join(', '),
this.statusOf(r).label,
r.form === '121' ? 'Form 121' : `Form ${r.form}`,
r.band === 'SENIOR' ? 'Senior citizen' : 'General',
r.age,
r.quarters[1],
r.quarters[2],
r.quarters[3],
r.quarters[4],
r.tdsApplicableInterest,
r.exemptInterest,
r.threshold,
r.crossedInQuarter ? `Q${r.crossedInQuarter}` : '—',
r.rateApplied * 100,
r.schedule.totalTds,
r.hasDeclaration ? 'Yes' : 'No',
r.declarationUin ?? '',
r.tdsSaved,
r.paidWithoutDeduction
]);
}
const s = this.summary;
ws.addRow([]);
ws.addRow([
'TOTAL',
`${s.deductees} deductees`,
'',
'',
`${s.trackedCustomers} tracked`,
'',
'',
'',
'',
'',
s.byQuarter[0].interest,
s.byQuarter[1].interest,
s.byQuarter[2].interest,
s.byQuarter[3].interest,
s.applicableInterest,
s.exemptInterest,
'',
'',
'',
s.totalTds,
`${s.declarations} filed`,
'',
s.tdsSaved,
s.paidWithoutDeduction
]);
ws.getRow(1).font = { bold: true, size: 13 };
ws.getRow(4).font = { bold: true };
ws.columns.forEach((c) => (c.width = 18));
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
@@ -439,7 +491,7 @@ export class TdsInterestReportComponent implements OnInit {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `TDS-Register-FY${this.filter.fy}.xlsx`;
a.download = `TDS-Interest-FY${this.filter.fy}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} finally {
@@ -13,329 +13,225 @@
<div>
<h2 class="tdsr__title">TDS Remitted to Government</h2>
<div class="tdsr__sub">
TDS deducted from customers' deposits each month, and the challans Head Office paid it with · Bank as a whole
@if (asAt) {
· as at {{ asAt | date: 'd MMM y' }}
}
Challan-wise deposits and the {{ statute.statement }} year-end position · {{ scopeLabel }} · as at
{{ asAt | date: 'd MMM y' }}
</div>
</div>
</div>
@if (isHeadOffice) {
<div class="tdsr__head-r">
<label class="tdsr__filter">
<span>Financial year</span>
<select [(ngModel)]="fy" (ngModelChange)="onYearChanged()">
@for (year of financialYears; track year) {
<option [value]="year">FY {{ year }}</option>
}
</select>
</label>
<button type="button" class="tdsr__btn" (click)="openForm()">
<fa-icon icon="plus"></fa-icon> Record challan
</button>
<button type="button" class="tdsr__btn" (click)="exportWorkbook()" [disabled]="exporting || !summary">
<fa-icon icon="download"></fa-icon>
{{ exporting ? 'Preparing…' : 'Export' }}
</button>
</div>
}
<div class="tdsr__head-r">
<label class="tdsr__filter">
<span>Financial year</span>
<select [(ngModel)]="fy">
@for (year of financialYears; track year) {
<option [value]="year">FY {{ year }}</option>
}
</select>
</label>
<button type="button" class="tdsr__btn" (click)="exportWorkbook()" [disabled]="exporting">
<fa-icon icon="download"></fa-icon>
{{ exporting ? 'Preparing…' : 'Export' }}
</button>
</div>
</div>
@if (!isHeadOffice) {
<div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="info-circle"></fa-icon>
<!-- The control figure, first thing on the page -->
@if (reconciled) {
<div class="tdsr__banner tdsr__banner--ok">
<fa-icon icon="check-circle"></fa-icon>
<div>
<strong>This report is for Head Office.</strong> TDS is paid to the Government by Head Office for the whole bank,
under one TAN. Your branch's customers and the TDS deducted from them are on
<em>Reports › Deposit Interest &amp; TDS</em>.
<strong>Reconciled.</strong>
₹{{ summary.totalDeducted | number: '1.0-0' }} deducted, ₹{{
summary.totalDeposited | number: '1.0-0'
}}
deposited, ₹{{ summary.pendingDeposit | number: '1.0-0' }} still within its due date.
</div>
</div>
} @else {
@if (loadError) {
<div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>{{ loadError }}</div>
</div>
}
<!-- Challan form -->
@if (showForm) {
<div class="tdsr__card tdsr__form">
<div class="tdsr__card-head"><fa-icon icon="receipt"></fa-icon> Record a challan — FY {{ fy }}</div>
<div class="tdsr__filters tdsr__filters--flat">
<label class="tdsr__filter">
<span>Deduction month *</span>
<input type="month" [(ngModel)]="form.month" />
</label>
<label class="tdsr__filter">
<span>Amount (₹) *</span>
<input type="number" min="0" step="1" [(ngModel)]="form.amount" />
</label>
<label class="tdsr__filter">
<span>Challan no. *</span>
<input type="text" [(ngModel)]="form.challanNo" placeholder="e.g. 00123" />
</label>
<label class="tdsr__filter">
<span>BSR code *</span>
<input type="text" maxlength="7" [(ngModel)]="form.bsrCode" placeholder="7 digits" />
</label>
<label class="tdsr__filter">
<span>Paid on *</span>
<input type="date" [(ngModel)]="form.depositedOn" />
</label>
<label class="tdsr__filter tdsr__filter--wide">
<span>Paid from account *</span>
<select [(ngModel)]="form.paidFromGlAccountId">
<option [ngValue]="0" disabled>Choose bank / cash account</option>
@for (o of paidFromOptions; track o.id) {
<option [ngValue]="o.id">{{ o.glCode }} · {{ o.name }}</option>
}
</select>
</label>
<label class="tdsr__filter tdsr__filter--wide">
<span>Remarks</span>
<input type="text" [(ngModel)]="form.remarks" />
</label>
</div>
<div class="tdsr__foot">
Saving posts <strong>Dr TDS payable / Cr the account paid from</strong> at Head Office, dated the day it was
paid.
</div>
@if (formError) {
<div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>{{ formError }}</div>
</div>
}
<div class="tdsr__form-actions">
<button type="button" class="tdsr__btn" (click)="showForm = false" [disabled]="saving">Cancel</button>
<button type="button" class="tdsr__btn tdsr__btn--primary" (click)="saveChallan()" [disabled]="saving">
{{ saving ? 'Saving…' : 'Save challan' }}
</button>
</div>
</div>
}
@if (summary) {
<!-- The control figure, first thing on the page -->
@if (reconciled) {
<div class="tdsr__banner tdsr__banner--ok">
<fa-icon icon="check-circle"></fa-icon>
<div>
<strong>Reconciled.</strong>
₹{{ summary.totalDeducted | number: '1.0-0' }} deducted, ₹{{ summary.totalDeposited | number: '1.0-0' }}
paid to the Government, ₹{{ summary.pendingDeposit | number: '1.0-0' }} still within its due date.
</div>
</div>
} @else {
<div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
<strong>Does not reconcile — ₹{{ summary.difference | number: '1.0-0' }} unaccounted.</strong>
Tax deducted must equal tax paid plus tax still within its due date.
@if (summary.overdueAmount > 0) {
₹{{ summary.overdueAmount | number: '1.0-0' }} is past its due date and not yet paid.
}
</div>
</div>
}
@if (summary.calculatedTds !== summary.totalDeducted) {
<div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
By the TDS rules ₹{{ summary.calculatedTds | number: '1.0-0' }} was due this year, but
₹{{ summary.totalDeducted | number: '1.0-0' }} was actually deducted from accounts. Open
<em>Deposit Interest &amp; TDS</em> to see which customers differ.
</div>
</div>
}
<!-- KPIs -->
<div class="tdsr__kpis">
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">₹{{ summary.totalDeducted | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">TDS deducted from accounts</div>
</div>
<div class="tdsr__kpi tdsr__kpi--ok">
<div class="tdsr__kpi-n">₹{{ summary.totalDeposited | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Paid to Government</div>
</div>
<div class="tdsr__kpi">
<div class="tdsr__kpi-n">₹{{ summary.pendingDeposit | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">To pay — still within due date</div>
</div>
<div class="tdsr__kpi" [class.tdsr__kpi--warn]="summary.overdueAmount > 0">
<div class="tdsr__kpi-n">₹{{ summary.overdueAmount | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Overdue — pay now</div>
</div>
<div class="tdsr__kpi" [class.tdsr__kpi--warn]="summary.defaultInterest > 0">
<div class="tdsr__kpi-n">₹{{ summary.defaultInterest | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Interest for paying late</div>
</div>
</div>
}
<!-- Part A: month by month -->
<div class="tdsr__card">
<div class="tdsr__card-head"><fa-icon icon="receipt"></fa-icon> Part A — month by month</div>
<div class="tdsr__table-wrap">
<table class="tdsr__table">
<thead>
<tr>
<th>Quarter</th>
<th>Deduction month</th>
<th class="num">Deducted</th>
<th>Pay by</th>
<th>Challans</th>
<th class="num">Paid</th>
<th class="num">Still to pay</th>
<th>Status</th>
<th class="num">Late interest</th>
<th class="col-actions"></th>
</tr>
</thead>
<tbody>
@for (row of rows; track trackRow($index, row)) {
<tr [class.tdsr__row--warn]="row.late || row.overdue">
<td class="small"><strong>{{ quarterLabel[row.quarter] }}</strong></td>
<td class="small">
{{ row.month + '-01' | date: 'MMM y' }}
<div class="muted">{{ row.postings }} deduction{{ row.postings === 1 ? '' : 's' }}</div>
</td>
<td class="num"><strong>₹{{ row.deducted | number: '1.0-2' }}</strong></td>
<td class="small">{{ row.dueDate | date: 'd MMM y' }}</td>
<td class="mono small">
@for (c of row.challans; track c.id) {
<div>
{{ c.challanNo }} · BSR {{ c.bsrCode }} · {{ c.depositedOn | date: 'd MMM y' }} · ₹{{
c.amount | number: '1.0-2'
}}
<button type="button" class="tdsr__link" (click)="reverseChallan(c)">reverse</button>
</div>
} @empty {
<span class="muted">—</span>
}
</td>
<td class="num">₹{{ row.deposited | number: '1.0-2' }}</td>
<td class="num">
@if (row.outstanding) {
<strong>₹{{ row.outstanding | number: '1.0-2' }}</strong>
} @else if (row.excess) {
<span class="tdsr__warn-text small">₹{{ row.excess | number: '1.0-2' }} paid extra</span>
} @else {
<span class="muted">—</span>
}
</td>
<td>
@if (row.overdue) {
<span class="tdsr__pill tdsr__pill--warn">Overdue</span>
} @else if (row.late) {
<span class="tdsr__pill tdsr__pill--warn">Paid late by {{ row.delayMonths }}m</span>
} @else if (row.outstanding) {
<span class="tdsr__pill">Within due date</span>
} @else {
<span class="tdsr__pill tdsr__pill--ok">Paid on time</span>
}
</td>
<td class="num">
@if (row.defaultInterest) {
<strong class="tdsr__warn-text">₹{{ row.defaultInterest | number: '1.0-0' }}</strong>
} @else {
<span class="muted">—</span>
}
</td>
<td class="col-actions">
@if (row.outstanding) {
<button type="button" class="tdsr__btn tdsr__btn--sm" (click)="openForm(row)">Pay</button>
}
</td>
</tr>
}
@if (rows.length === 0) {
<tr>
<td colspan="10" class="tdsr__empty">
<fa-icon icon="info-circle"></fa-icon>
{{ loading ? 'Loading…' : 'No TDS was deducted from any account in this financial year.' }}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="tdsr__foot">
Tax deducted in a month must reach the Government by the seventh of the following month; March runs to 30 April.
Interest runs at 1.5% for every month or part of a month it is late.
<div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
<strong>Does not reconcile — ₹{{ summary.difference | number: '1.0-0' }} unaccounted.</strong>
Tax deducted must equal tax deposited plus tax still within its due date. Find the difference before filing the
return.
</div>
</div>
<!-- Part B: return summary -->
@if (summary) {
<div class="tdsr__card">
<div class="tdsr__card-head">
<fa-icon icon="file-alt"></fa-icon> Part B — {{ statute.statement }} year-end position
</div>
<div class="tdsr__ledger">
<div class="tdsr__ledger-row">
<span>Deductees (customers with an FD/RD)</span>
<span class="num">{{ summary.deductees | number }}</span>
</div>
<div class="tdsr__ledger-row">
<span>Total interest paid on time deposits</span>
<span class="num">₹{{ summary.totalInterestPaid | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--indent">
<span>with tax deducted</span>
<span class="num">₹{{ summary.interestWithDeduction | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--indent">
<span>
without deduction — declaration on record
<em>mandatory disclosure in {{ statute.statement }}</em>
</span>
<span class="num">₹{{ summary.interestWithoutDeduction | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--sep">
<span>TDS at standard rate — PAN on record</span>
<span class="num">₹{{ summary.tdsAtStandardRate | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row">
<span>TDS at higher rate — no PAN on record</span>
<span class="num">₹{{ summary.tdsAtHigherRate | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--total">
<span>Total TDS deducted</span>
<span class="num">₹{{ summary.totalDeducted | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row">
<span>Paid to Government</span>
<span class="num">₹{{ summary.totalDeposited | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row">
<span>Still within due date</span>
<span class="num">₹{{ summary.pendingDeposit | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--control" [class.tdsr__ledger-row--bad]="!reconciled">
<span>Difference — must be zero</span>
<span class="num">₹{{ summary.difference | number: '1.0-0' }}</span>
</div>
@if (summary.defaultInterest > 0) {
<div class="tdsr__ledger-row tdsr__ledger-row--sep">
<span>Interest payable by the bank for late deposit</span>
<span class="num">₹{{ summary.defaultInterest | number: '1.0-0' }}</span>
</div>
}
</div>
<div class="tdsr__foot">
The "without deduction" line is not optional — interest paid gross against a declaration must be reported in the
quarterly {{ statute.statement }} return with the appropriate remark code, and the consolidated statement of
declarations received is uploaded {{ statute.frequency }}. Confirm the remark code against the current
specification before generating the file.
</div>
</div>
}
}
@if (summary.overdueChallans > 0) {
<div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
<strong>{{ summary.overdueChallans }} challan{{ summary.overdueChallans === 1 ? '' : 's' }} overdue.</strong>
Interest runs at 1.5% for every month or part of a month until the tax reaches the Government.
</div>
</div>
}
<!-- KPIs -->
<div class="tdsr__kpis">
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">₹{{ summary.totalDeducted | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Total TDS deducted</div>
</div>
<div class="tdsr__kpi tdsr__kpi--ok">
<div class="tdsr__kpi-n">₹{{ summary.totalDeposited | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Deposited with Government</div>
</div>
<div class="tdsr__kpi">
<div class="tdsr__kpi-n">₹{{ summary.pendingDeposit | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Within due date</div>
</div>
<div class="tdsr__kpi" [class.tdsr__kpi--warn]="totalDefaultInterest > 0">
<div class="tdsr__kpi-n">₹{{ totalDefaultInterest | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Interest on late deposit</div>
</div>
<div class="tdsr__kpi">
<div class="tdsr__kpi-n">{{ summary.deductees | number }}</div>
<div class="tdsr__kpi-l">Deductees in the return</div>
</div>
</div>
<!-- Part A: challans -->
<div class="tdsr__card">
<div class="tdsr__card-head"><fa-icon icon="receipt"></fa-icon> Part A — challan-wise remittance</div>
<div class="tdsr__table-wrap">
<table class="tdsr__table">
<thead>
<tr>
<th>Quarter</th>
<th>Deduction month</th>
<th class="num">Amount</th>
<th>Due date</th>
<th>Deposited on</th>
<th>Challan · BSR</th>
<th>Status</th>
<th class="num">Late-deposit interest</th>
<th>{{ statute.statement }} due</th>
</tr>
</thead>
<tbody>
@for (row of rows; track trackRow($index, row)) {
<tr [class.tdsr__row--warn]="row.late || row.overdue">
<td>
<strong>{{ quarterLabel[row.quarter] }}</strong>
</td>
<td class="small">{{ row.month }}</td>
<td class="num">
<strong>₹{{ row.amount | number: '1.0-0' }}</strong>
</td>
<td class="small">{{ row.dueDate | date: 'd MMM y' }}</td>
<td class="small">
@if (row.depositedOn) {
{{ row.depositedOn | date: 'd MMM y' }}
} @else {
<span class="muted">—</span>
}
</td>
<td class="mono small">
@if (row.challanNo) {
{{ row.challanNo }}
<div class="muted">BSR {{ row.bsrCode }}</div>
} @else {
<span class="muted">—</span>
}
</td>
<td>
@if (row.overdue) {
<span class="tdsr__pill tdsr__pill--warn">Overdue</span>
} @else if (row.late) {
<span class="tdsr__pill tdsr__pill--warn">Late by {{ row.delayMonths }}m</span>
} @else if (row.depositedOn) {
<span class="tdsr__pill tdsr__pill--ok">On time</span>
} @else {
<span class="tdsr__pill">Within due date</span>
}
</td>
<td class="num">
@if (row.defaultInterest) {
<strong class="tdsr__warn-text">₹{{ row.defaultInterest | number: '1.0-0' }}</strong>
<div class="small muted">1.5% × {{ row.delayMonths }}</div>
} @else {
<span class="muted">—</span>
}
</td>
<td class="small muted">{{ row.returnDueDate }}</td>
</tr>
}
@if (rows.length === 0) {
<tr>
<td colspan="9" class="tdsr__empty">
<fa-icon icon="info-circle"></fa-icon> No tax was deducted in this financial year.
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="tdsr__foot">
Tax deducted in a month must reach the Government by the seventh of the following month; March runs to 30 April.
</div>
</div>
<!-- Part B: return summary -->
<div class="tdsr__card">
<div class="tdsr__card-head">
<fa-icon icon="file-alt"></fa-icon> Part B — {{ statute.statement }} year-end position
</div>
<div class="tdsr__ledger">
<div class="tdsr__ledger-row">
<span>Total interest paid on time deposits</span>
<span class="num">₹{{ summary.totalInterestPaid | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--indent">
<span>with tax deducted</span>
<span class="num">₹{{ summary.interestWithDeduction | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--indent">
<span>
without deduction — declaration on record
<em>mandatory disclosure in {{ statute.statement }}</em>
</span>
<span class="num">₹{{ summary.interestWithoutDeduction | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--sep">
<span>TDS at standard rate — PAN on record</span>
<span class="num">₹{{ summary.tdsAtStandardRate | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row">
<span>TDS at higher rate — no PAN on record</span>
<span class="num">₹{{ summary.tdsAtHigherRate | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--total">
<span>Total TDS deducted</span>
<span class="num">₹{{ summary.totalDeducted | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row">
<span>Deposited with Government</span>
<span class="num">₹{{ summary.totalDeposited | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row">
<span>Still within due date</span>
<span class="num">₹{{ summary.pendingDeposit | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--control" [class.tdsr__ledger-row--bad]="!reconciled">
<span>Difference — must be zero</span>
<span class="num">₹{{ summary.difference | number: '1.0-0' }}</span>
</div>
@if (summary.defaultInterest > 0) {
<div class="tdsr__ledger-row tdsr__ledger-row--sep">
<span>Interest payable by the bank for late deposit</span>
<span class="num">₹{{ summary.defaultInterest | number: '1.0-0' }}</span>
</div>
}
</div>
<div class="tdsr__foot">
The "without deduction" line is not optional — interest paid gross against a declaration must be reported in the
quarterly {{ statute.statement }} return with the appropriate remark code, and the consolidated statement of
declarations received is uploaded {{ statute.frequency }}. Confirm the remark code against the current
specification before generating the file.
</div>
</div>
</div>
@@ -6,25 +6,17 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import * as ExcelJS from 'exceljs';
import { forkJoin } from 'rxjs';
import { BranchScopeService } from '../core/branch-scope.service';
import { FY_TAX_CONFIG, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { TdsChallan, TdsDeductedMonth, setTdsDeductees, tdsFys } from './tds-reports-data';
import { NewTdsChallan, TdsReportsService } from './tds-reports.service';
import { TDS_AS_AT, tdsFys } from './tds-reports-data';
import { Tds26qSummary, TdsRemittanceRow, tds26qSummary, tdsRemittanceRows } from './tds-reports.compute';
/**
* Tax deducted against tax remitted, month by month, for Head Office.
*
* The deducted side is the withhold-tax postings on deposits; the remitted side is the challans
* entered here. Both are records — nothing on this page is worked out from a rate.
*/
@Component({
selector: 'mifosx-tds-remittance-report',
templateUrl: './tds-remittance-report.component.html',
@@ -35,83 +27,22 @@ import { Tds26qSummary, TdsRemittanceRow, tds26qSummary, tdsRemittanceRows } fro
FormsModule
]
})
export class TdsRemittanceReportComponent implements OnInit {
export class TdsRemittanceReportComponent {
private scope = inject(BranchScopeService);
private tdsService = inject(TdsReportsService);
/** Filled by the route resolver from Organization › TDS Configuration. */
readonly financialYears = tdsFys();
readonly quarterLabel = QUARTER_LABEL;
readonly asAt = TDS_AS_AT;
fy = tdsFys()[0] ?? '';
exporting = false;
loading = false;
loadError = '';
/** The business date the platform is on — what "overdue" is judged against. */
asAt = '';
private deducted: TdsDeductedMonth[] = [];
challans: TdsChallan[] = [];
rows: TdsRemittanceRow[] = [];
summary: Tds26qSummary | null = null;
paidFromOptions: { id: number; glCode: string; name: string }[] = [];
showForm = false;
saving = false;
formError = '';
form: NewTdsChallan = this.blankForm();
/** Challans are remitted by Head Office under the bank's one TAN; a branch has nothing to enter here. */
get isHeadOffice(): boolean {
return this.scope.isHeadOffice;
}
ngOnInit(): void {
if (!this.isHeadOffice || !this.fy) {
return;
}
this.load();
this.tdsService.getChallanTemplate().subscribe({
next: (t) => (this.paidFromOptions = t?.paidFromOptions || []),
error: () => (this.paidFromOptions = [])
});
}
load(): void {
this.loading = true;
this.loadError = '';
const openingYear = Number(this.fy.slice(0, 4));
forkJoin({
deducted: this.tdsService.getDeducted(this.fy),
challans: this.tdsService.getChallans(this.fy),
// Part B summarises the year per customer, so the interest register is loaded too.
deductees: this.tdsService.getDepositInterest(openingYear)
}).subscribe({
next: ({ deducted, challans, deductees }) => {
this.loading = false;
this.asAt = deducted.asAt;
this.deducted = deducted.months;
this.challans = challans;
setTdsDeductees(deductees);
this.recompute();
},
error: (err: any) => {
this.loading = false;
this.loadError = err?.error?.defaultUserMessage || 'Could not load the remittance position.';
}
});
}
private recompute(): void {
this.rows = tdsRemittanceRows(this.fy, this.deducted, this.challans, this.asAt);
this.summary = tds26qSummary(this.fy, this.rows, this.asAt);
}
onYearChanged(): void {
this.showForm = false;
this.load();
}
/**
* Provision and return references for the selected year. The quarterly return
* became Form 140 under the 2025 Act, so the labels are read from the config
* rather than written into the template.
*/
get statute(): { section: string; statement: string; frequency: string } {
const cfg = FY_TAX_CONFIG[this.fy];
return {
@@ -121,85 +52,53 @@ export class TdsRemittanceReportComponent implements OnInit {
};
}
get scopeLabel(): string {
return this.scope.isHeadOffice ? 'Bank as a whole' : this.scope.officeName;
}
get rows(): TdsRemittanceRow[] {
return tdsRemittanceRows(this.fy, this.asAt);
}
get summary(): Tds26qSummary {
return tds26qSummary(this.fy, this.asAt);
}
/**
* The control that matters. Tax deducted must equal tax deposited plus tax
* still within its due date. Anything else means something has gone missing
* and must be found before the return is filed.
*/
get reconciled(): boolean {
return !!this.summary && Math.abs(this.summary.difference) < 1;
return this.summary.difference === 0;
}
/** Opens the challan form, pre-filled for a month that still has tax outstanding. */
openForm(row?: TdsRemittanceRow): void {
this.form = this.blankForm();
if (row) {
this.form.month = row.month;
this.form.amount = row.outstanding;
}
this.formError = '';
this.showForm = true;
}
saveChallan(): void {
this.formError = '';
const f = this.form;
if (!f.month || !f.amount || !f.challanNo || !f.bsrCode || !f.depositedOn || !f.paidFromGlAccountId) {
this.formError = 'Fill in every field marked *.';
return;
}
this.saving = true;
this.tdsService.createChallan({ ...f, fy: this.fy, amount: Number(f.amount) }).subscribe({
next: (challans) => {
this.saving = false;
this.showForm = false;
this.challans = challans;
this.recompute();
},
error: (err: any) => {
this.saving = false;
this.formError = err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Could not save.';
}
});
}
reverseChallan(c: TdsChallan): void {
if (!confirm(`Reverse challan ${c.challanNo} for ₹${c.amount}? Its accounting entry will be undone.`)) {
return;
}
this.tdsService.reverseChallan(c.id).subscribe({
next: (challans) => {
this.challans = challans;
this.recompute();
},
error: (err: any) => alert(err?.error?.defaultUserMessage || 'Could not reverse the challan.')
});
}
private blankForm(): NewTdsChallan {
return { fy: this.fy, month: '', amount: 0, challanNo: '', bsrCode: '', depositedOn: '', paidFromGlAccountId: 0, remarks: '' };
get totalDefaultInterest(): number {
return this.summary.defaultInterest;
}
trackRow(_: number, row: TdsRemittanceRow): string {
return row.month;
return `${row.fy}-${row.quarter}`;
}
async exportWorkbook(): Promise<void> {
const s = this.summary;
if (!s) {
return;
}
this.exporting = true;
try {
const s = this.summary;
const wb = new ExcelJS.Workbook();
const challan = wb.addWorksheet('Challans');
challan.addRow([`TDS remitted to Government — FY ${this.fy}`]);
challan.addRow([`Bank as a whole · as at ${this.asAt}`]);
challan.addRow([`Scope: ${this.scopeLabel} · as at ${this.asAt}`]);
challan.addRow([]);
challan.addRow([
'Quarter',
'Deduction month',
'Deducted',
'Amount deducted',
'Due date',
'Deposited',
'Challans',
'Outstanding',
'Deposited on',
'Challan no.',
'BSR code',
'Status',
'Delay (months)',
'Late-deposit interest',
@@ -209,12 +108,12 @@ export class TdsRemittanceReportComponent implements OnInit {
challan.addRow([
`Q${r.quarter}`,
r.month,
r.deducted,
r.amount,
r.dueDate,
r.deposited,
r.challans.map((c) => `${c.challanNo} / BSR ${c.bsrCode} / ${c.depositedOn}`).join('; '),
r.outstanding,
r.overdue ? 'OVERDUE' : r.late ? 'LATE' : r.outstanding ? 'Within due date' : 'Deposited',
r.depositedOn ?? 'PENDING',
r.challanNo ?? '',
r.bsrCode ?? '',
r.overdue ? 'OVERDUE' : r.late ? 'LATE' : r.depositedOn ? 'On time' : 'Within due date',
r.delayMonths || '',
r.defaultInterest || '',
r.returnDueDate
@@ -228,19 +127,58 @@ export class TdsRemittanceReportComponent implements OnInit {
recon.addRow([`${this.statute.statement} year-end position — FY ${this.fy}`]);
recon.addRow([]);
const lines: [string, number | string][] = [
['Deductees', s.deductees],
['Total interest paid on time deposits', s.totalInterestPaid],
[' — with tax deducted', s.interestWithDeduction],
[' — without deduction (declaration on record)', s.interestWithoutDeduction],
['TDS at standard rate (PAN on record)', s.tdsAtStandardRate],
['TDS at higher rate (no PAN)', s.tdsAtHigherRate],
['TDS due by the rules', s.calculatedTds],
['Total TDS actually deducted', s.totalDeducted],
['Total deposited with Government', s.totalDeposited],
['Still within due date', s.pendingDeposit],
['Overdue', s.overdueAmount],
['DIFFERENCE (must be zero)', s.difference],
['Interest payable for late deposit', s.defaultInterest]
[
'Deductees',
s.deductees
],
[
'Total interest paid on time deposits',
s.totalInterestPaid
],
[
' — with tax deducted',
s.interestWithDeduction
],
[
' — without deduction (declaration on record)',
s.interestWithoutDeduction
],
[
'TDS at standard rate (PAN on record)',
s.tdsAtStandardRate
],
[
'TDS at higher rate (no PAN)',
s.tdsAtHigherRate
],
[
'Total TDS deducted',
s.totalDeducted
],
[
'Total deposited with Government',
s.totalDeposited
],
[
'Still within due date',
s.pendingDeposit
],
[
'DIFFERENCE (must be zero)',
s.difference
],
[
'Interest payable for late deposit',
s.defaultInterest
],
[
'Challans deposited late',
s.lateChallans
],
[
'Challans overdue',
s.overdueChallans
]
];
for (const line of lines) {
recon.addRow(line);
+167 -27
View File
@@ -7,16 +7,22 @@
*/
/**
* Deductees, their deposit interest and the challans, for the two TDS reports.
* Deductees and their deposit interest, for the two TDS reports.
*
* Everything here is what the platform returned. The challans used to be generated from the
* computed deductions - challan numbers, BSR codes and deposit dates made up, one quarter
* deliberately late - so the remittance report reconciled against figures nobody had entered.
* The bank's deduction obligation runs on time deposits, so the seed
* gives each customer a mix of fixed and recurring deposits — and some savings
* accounts, which are outside the section and must stay out of the aggregate.
*
* Everything is generated from a fixed seed so the figures do not move between
* loads, and the challan schedule is deliberately imperfect: one quarter is
* deposited late so the default-interest column has something to show.
*/
import { GL_BRANCHES } from '../gl-reports/gl-reports-data';
import {
configuredFys,
Declaration,
Form15Kind,
InterestCredit,
TdsAccountInput
} from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
@@ -26,14 +32,8 @@ export function tdsFys(): string[] {
return configuredFys();
}
/** A deposit as the platform reports it: the interest credited and the TDS actually withheld. */
export interface TdsReportAccount extends TdsAccountInput {
/** TDS the platform actually deducted from this account in the year. */
deductions: InterestCredit[];
/** The account's own holder, when the tax is deducted against somebody else (minor, joint). */
holderClientId?: number;
holderName?: string;
}
/** Business date the book is drawn to — Q1 of 2026-27 is closed, Q2 is running. */
export const TDS_AS_AT = '2026-08-01';
export interface TdsDeductee {
clientId: number;
@@ -44,35 +44,115 @@ export interface TdsDeductee {
branchCode: string;
/** Declarations on record, keyed by financial year. */
declarations: Record<string, Declaration>;
accounts: TdsReportAccount[];
accounts: TdsAccountInput[];
}
/** One challan Head Office entered: tax remitted to the Government. */
/** One month's remittance of deducted tax to the Government. */
export interface TdsChallan {
id: number;
fy: string;
/** Quarter the deduction belongs to. */
quarter: 1 | 2 | 3 | 4;
/** Month the tax was deducted in, e.g. '2026-06'. */
month: string;
amount: number;
challanNo: string;
bsrCode: string;
depositedOn: string;
paidFrom: string;
remarks: string | null;
reversed: boolean;
/** Statutory date by which it had to be deposited. */
dueDate: string;
/** When it actually reached the Government — null while still pending. */
depositedOn: string | null;
challanNo: string | null;
bsrCode: string | null;
}
/** Tax the platform withheld in one month. */
export interface TdsDeductedMonth {
month: string;
amount: number;
postings: number;
/** Deterministic linear congruential generator — same figures on every load. */
function lcg(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function pick<T>(rand: () => number, list: T[]): T {
return list[Math.floor(rand() * list.length)];
}
const FIRST_NAMES = [
'Ramesh',
'Sunita',
'Vitthal',
'Kamala',
'Ganpat',
'Shobha',
'Dnyaneshwar',
'Mangala',
'Bhaskar',
'Anita',
'Tukaram',
'Sarika',
'Nivrutti',
'Rekha',
'Popat',
'Chhaya',
'Ashok',
'Vaishali',
'Sitaram',
'Jayshree',
'Madhukar',
'Pushpa',
'Eknath',
'Sulochana',
'Hanumant',
'Kalpana'
];
const SURNAMES = [
'Jadhav',
'Pawar',
'Shinde',
'Gaikwad',
'More',
'Kadam',
'Bhosale',
'Patil',
'Sawant',
'Chavan',
'Salunkhe',
'Mane',
'Deshmukh',
'Thorat',
'Nikam',
'Wagh'
];
/**
* Quarter-end dates for a financial year. Interest is credited on these, which
* is what makes the deduction fall due.
*/
function quarterEnds(fy: string): string[] {
const y = Number(fy.slice(0, 4));
return [
`${y}-06-30`,
`${y}-09-30`,
`${y}-12-31`,
`${y + 1}-03-31`
];
}
/** A well-formed individual PAN — fourth character 'P'. */
function makePan(rand: () => number, surname: string): string {
const letter = () => String.fromCharCode(65 + Math.floor(rand() * 26));
const digits = `${1000 + Math.floor(rand() * 9000)}`;
return `${letter()}${letter()}${letter()}P${surname[0].toUpperCase()}${digits}${letter()}`;
}
/**
* The deductees the reports run on, loaded from the platform.
*
* It is set once by whoever loads the screen and read by the pure computation, which is why
* This used to be sixty-four customers generated from a fixed seed — names, PANs, deposits and all.
* The computation over them is the real section 194A calculation, so the figures looked like a
* filing and were not one: not a rupee of it came from the bank's own books.
*
* It is set once by whoever loads the screen and read by the pure computation below, which is why
* this is a module-level holder rather than a service the compute would have to inject.
*/
let liveDeductees: TdsDeductee[] = [];
@@ -108,3 +188,63 @@ export function depositDueDate(month: string): string {
const nextM = m === 12 ? 1 : m + 1;
return `${nextY}-${`${nextM}`.padStart(2, '0')}-07`;
}
let challanCache: Record<string, TdsChallan[]> | null = null;
/**
* The remittance schedule, derived from what the engine says was deducted.
*
* Deductions land at each quarter end, so there is one challan per quarter. The
* Q2 challan of the prior year is deliberately deposited a month and a bit late
* so the late-deposit interest column is exercised, and the current year's latest
* quarter is left pending, which is the ordinary state of a live book.
*/
export function tdsChallans(deductedByQuarter: Record<string, Record<number, number>>): Record<string, TdsChallan[]> {
if (challanCache) {
return challanCache;
}
const out: Record<string, TdsChallan[]> = {};
for (const fy of Object.keys(deductedByQuarter)) {
const y = Number(fy.slice(0, 4));
const months: Record<number, string> = {
1: `${y}-06`,
2: `${y}-09`,
3: `${y}-12`,
4: `${y + 1}-03`
};
const rows: TdsChallan[] = [];
for (const q of [
1,
2,
3,
4
] as (1 | 2 | 3 | 4)[]) {
const amount = Math.round(deductedByQuarter[fy]?.[q] ?? 0);
if (amount <= 0) {
continue;
}
const month = months[q];
const dueDate = depositDueDate(month);
// Anything falling after the business date has not been deposited yet.
const pending = dueDate > TDS_AS_AT;
const late = fy === '2025-26' && q === 2;
rows.push({
fy,
quarter: q,
month,
amount,
dueDate,
depositedOn: pending ? null : late ? `${y}-11-14` : dueDate,
challanNo: pending ? null : `${y}${q}${`${amount % 10000}`.padStart(4, '0')}`,
bsrCode: pending ? null : '0510308'
});
}
out[fy] = rows;
}
challanCache = out;
return out;
}
+219 -229
View File
@@ -6,10 +6,13 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { isTdsApplicable, tdsDeducteeOf } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { installTdsConfigFixture } from '../letter-mgmt/certificates/form-15gh/tds-config.fixture';
import { TdsChallan, TdsDeductee, depositDueDate, setTdsDeductees } from './tds-reports-data';
import { TDS_AS_AT, depositDueDate, tdsDeductees, tdsFys } from './tds-reports-data';
import {
NEAR_THRESHOLD_BAND,
TdsReportFilter,
deductedByQuarter,
tds26qSummary,
tdsInterestRows,
tdsInterestRowsForFy,
@@ -19,81 +22,12 @@ import {
installTdsConfigFixture();
/**
* A small book with every case the reports must handle once, so each figure below can be worked
* out by hand. The reports used to run on sixty-four generated customers; the tests asserted
* shapes ("some row crossed") because nobody could know the numbers.
*/
const FY = '2025-26';
const Q = ['2025-06-30', '2025-09-30', '2025-12-31', '2026-03-31'];
const fd = (id: number, amounts: number[], extra: object = {}) => ({
id,
accountNo: `FD${id}`,
depositTypeCode: 'depositAccountType.fixedDeposit',
credits: amounts.map((amount, i) => ({ date: Q[i], amount, accountNo: `FD${id}` })),
deductions: [] as { date: string; amount: number }[],
...extra
});
const sb = (id: number, amount: number) => ({
id,
accountNo: `SB${id}`,
depositTypeCode: 'depositAccountType.savingsDeposit',
credits: [{ date: '2025-09-30', amount, accountNo: `SB${id}` }],
deductions: [] as { date: string; amount: number }[]
});
function person(
clientId: number,
name: string,
dob: string,
pan: string,
branch: string,
accounts: any[],
declarations = {}
): TdsDeductee {
return {
clientId,
customerId: `C${clientId}`,
name,
pan,
dob,
branchCode: branch,
declarations,
accounts: accounts.map((a) => ({ clientId, ...a }))
};
}
const BOOK: TdsDeductee[] = [
// Over the limit in Q4, plus a savings account that must stay outside the figure.
person(1, 'Ramesh', '1980-05-01', 'ABCPR1234K', 'Head Office', [
{ ...fd(11, [15_000, 15_000, 15_000, 15_000]), deductions: [{ date: Q[3], amount: 6_000 }] },
sb(12, 1_200)
]),
// Senior citizen, no PAN: higher threshold, higher rate - and the platform deducted too little.
person(2, 'Sunita', '1955-01-01', '', 'bhopal', [
{ ...fd(21, [30_000, 30_000, 30_000, 30_000]), deductions: [{ date: Q[3], amount: 20_000 }] }
]),
// ₹6,000 short of the limit: the customer to ask for a declaration.
person(3, 'Vitthal', '1985-03-03', 'ABCPV5678L', 'bhopal', [fd(31, [22_000, 22_000])]),
// Declaration accepted mid-year: interest before it stays liable, interest after it is paid gross.
person(4, 'Kamala', '1979-07-07', 'ABCPK9012M', 'Head Office', [fd(41, [20_000, 20_000, 20_000, 20_000])], {
[FY]: { kind: '15G', acceptedOn: '2025-10-15', fy: FY, uin: 'UIN0001' }
}),
// A minor's deposit, already listed under the guardian by the platform.
person(5, 'Ganpat', '1975-02-02', 'ABCPG3456N', 'Head Office', [
{ ...fd(51, [5_000]), holderClientId: 99, holderName: 'Baby Ganpat' }
]),
// Savings only, no PAN: never deducted, so never counted as a no-PAN problem.
person(6, 'Shobha', '1990-09-09', '', 'Head Office', [sb(61, 900)])
];
setTdsDeductees(BOOK);
/** The complete year in the seed — the one a return would be filed for. */
const CLOSED_FY = '2025-26';
function filter(overrides: Partial<TdsReportFilter> = {}): TdsReportFilter {
return {
fy: FY,
fy: CLOSED_FY,
branchCode: '',
quarter: 0,
band: '',
@@ -104,94 +38,115 @@ function filter(overrides: Partial<TdsReportFilter> = {}): TdsReportFilter {
};
}
const row = (name: string) => tdsInterestRowsForFy(FY).find((r) => r.deductee.name === name)!;
describe('which accounts are tracked', () => {
it('counts the FD and leaves the savings account out, saying why', () => {
const r = row('Ramesh');
expect(r.tdsApplicableInterest).toBe(60_000);
expect(r.exemptInterest).toBe(1_200);
expect(r.trackedAccounts).toBe(1);
const savings = r.accounts.find((a) => a.type === 'SB')!;
expect(savings.tracked).toBe(false);
expect(savings.reason).toContain('Savings');
expect(r.accounts[0].type).toBe('FD');
describe('the seed has every case the reports need to show', () => {
it('produces deductees on both sides of the threshold', () => {
const rows = tdsInterestRowsForFy(CLOSED_FY);
expect(rows.length).toBeGreaterThan(20);
expect(rows.some((r) => r.crossed)).toBe(true);
expect(rows.some((r) => !r.crossed)).toBe(true);
expect(rows.some((r) => r.nearThreshold)).toBe(true);
expect(rows.some((r) => r.band === 'GENERAL')).toBe(true);
expect(rows.some((r) => r.band === 'SENIOR')).toBe(true);
expect(rows.some((r) => !r.panOnRecord)).toBe(true);
expect(rows.some((r) => r.hasDeclaration)).toBe(true);
});
it('shows whose account a deposit is when the tax falls on the guardian', () => {
const line = row('Ganpat').accounts[0];
expect(line.tracked).toBe(true);
expect(line.holderName).toBe('Baby Ganpat');
it('gives senior citizens the higher threshold', () => {
const rows = tdsInterestRowsForFy(CLOSED_FY);
for (const row of rows) {
expect(row.threshold).toBe(row.band === 'SENIOR' ? 100_000 : 50_000);
}
});
it('marks a savings-only customer as not tracked', () => {
expect(row('Shobha').trackedAccounts).toBe(0);
it('deducts at the higher rate wherever PAN is missing', () => {
for (const row of tdsInterestRowsForFy(CLOSED_FY)) {
expect(row.rateApplied).toBe(row.panOnRecord ? 0.1 : 0.2);
}
});
});
describe('the threshold and the rate', () => {
it('deducts on the whole year once the general limit is crossed', () => {
const r = row('Ramesh');
expect(r.threshold).toBe(50_000);
expect(r.crossedInQuarter).toBe(4);
expect(r.schedule.totalTds).toBe(6_000);
describe('the reports follow the Act that governed each year', () => {
it('shows 15G / 15H for FY 2025-26 and Form 121 for FY 2026-27', () => {
const old = tdsInterestRowsForFy('2025-26');
expect(old.some((r) => r.form === '15G')).toBe(true);
expect(old.some((r) => r.form === '15H')).toBe(true);
expect(old.some((r) => r.form === '121')).toBe(false);
// The two forms merged on 01 April 2026.
const current = tdsInterestRowsForFy('2026-27');
expect(current.every((r) => r.form === '121')).toBe(true);
});
it('gives a senior the higher limit, and a missing PAN the higher rate', () => {
const r = row('Sunita');
expect(r.band).toBe('SENIOR');
expect(r.threshold).toBe(100_000);
expect(r.rateApplied).toBe(0.2);
expect(r.schedule.totalTds).toBe(24_000);
});
it('flags a customer just under the limit', () => {
const r = row('Vitthal');
expect(r.crossed).toBe(false);
expect(r.nearThreshold).toBe(true);
expect(r.headroom).toBe(6_000);
expect(r.schedule.totalTds).toBe(0);
it('keeps the age-based threshold band in both years', () => {
for (const fy of tdsFys()) {
const rows = tdsInterestRowsForFy(fy);
expect(rows.some((r) => r.band === 'SENIOR')).toBe(true);
expect(rows.some((r) => r.band === 'GENERAL')).toBe(true);
for (const row of rows) {
expect(row.threshold).toBe(row.band === 'SENIOR' ? 100_000 : 50_000);
}
}
});
});
describe('a mid-year declaration', () => {
it('taxes only the interest credited before it was accepted', () => {
const r = row('Kamala');
expect(r.hasDeclaration).toBe(true);
// ₹40,000 credited before 15 Oct is liable once the year's ₹80,000 crosses the limit.
expect(r.schedule.totalTds).toBe(4_000);
expect(r.tdsSaved).toBe(4_000);
expect(r.paidWithoutDeduction).toBe(40_000);
});
});
describe('savings interest stays out of the 194A aggregate', () => {
it('reports it separately and never inside the applicable interest', () => {
const rows = tdsInterestRowsForFy(CLOSED_FY);
const withSavings = rows.filter((r) => r.exemptInterest > 0);
expect(withSavings.length).toBeGreaterThan(0);
describe('due against actually deducted', () => {
it('agrees where the platform deducted the right amount', () => {
expect(row('Ramesh').actualTds).toBe(6_000);
for (const row of withSavings) {
// The applicable figure must equal only the time-deposit interest where
// this customer is the deductee.
const expected = row.deductee.accounts
.filter((a) => isTdsApplicable(a) && tdsDeducteeOf(a) === row.deductee.clientId)
.flatMap((a) => a.credits)
.filter((c) => c.date >= '2025-04-01' && c.date <= '2026-03-31')
.reduce((s, c) => s + c.amount, 0);
expect(row.tdsApplicableInterest).toBe(expected);
}
});
it('shows the gap where it did not', () => {
const r = row('Sunita');
expect(r.actualTds).toBe(20_000);
expect(r.schedule.totalTds - r.actualTds).toBe(4_000);
});
it('totals both in the summary', () => {
it('leaves the savings totals out of the summary base', () => {
const s = tdsInterestSummary(filter());
expect(s.totalTds).toBe(6_000 + 24_000 + 4_000);
expect(s.actualTds).toBe(26_000);
expect(s.exemptInterest).toBeGreaterThan(0);
expect(s.applicableInterest).toBe(tdsInterestRows(filter()).reduce((acc, r) => acc + r.tdsApplicableInterest, 0));
});
});
describe('the summary', () => {
it('counts a missing PAN only where a deposit is being tracked', () => {
const s = tdsInterestSummary(filter());
expect(s.noPan).toBe(1);
expect(s.trackedCustomers).toBe(5);
expect(s.exemptInterest).toBe(2_100);
describe('joint deposits are attributed to the first-named holder only', () => {
it('excludes second-holder interest from the aggregate and shows it apart', () => {
const rows = tdsInterestRowsForFy(CLOSED_FY);
const secondHolders = rows.filter((r) => r.jointHeldInterest > 0);
expect(secondHolders.length).toBeGreaterThan(0);
for (const row of secondHolders) {
const jointAccounts = row.deductee.accounts.filter(
(a) => isTdsApplicable(a) && tdsDeducteeOf(a) !== row.deductee.clientId
);
expect(jointAccounts.length).toBeGreaterThan(0);
// None of that interest may appear in the figure the threshold is tested
// against, or one deposit would be taxed against two people.
const jointTotal = jointAccounts
.flatMap((a) => a.credits)
.filter((c) => c.date >= '2025-04-01' && c.date <= '2026-03-31')
.reduce((s, c) => s + c.amount, 0);
expect(row.jointHeldInterest).toBe(jointTotal);
expect(row.tdsApplicableInterest).toBeLessThan(row.tdsApplicableInterest + jointTotal);
}
});
});
describe('quarter figures add up to the year', () => {
it('per row', () => {
for (const row of tdsInterestRowsForFy(CLOSED_FY)) {
const quarterInterest = row.quarters[1] + row.quarters[2] + row.quarters[3] + row.quarters[4];
expect(quarterInterest).toBe(row.tdsApplicableInterest);
expect(row.schedule.lines.reduce((s, l) => s + l.tds, 0)).toBe(row.schedule.totalTds);
}
});
it('adds the quarters up to the year', () => {
it('and across the summary', () => {
const s = tdsInterestSummary(filter());
expect(s.byQuarter.reduce((acc, q) => acc + q.interest, 0)).toBe(s.applicableInterest);
expect(s.byQuarter.reduce((acc, q) => acc + q.tds, 0)).toBe(s.totalTds);
@@ -199,116 +154,151 @@ describe('the summary', () => {
});
describe('filters', () => {
it('narrows to a branch by office name, whatever its case, without re-aggregating', () => {
const scoped = tdsInterestRows(filter({ branchCode: 'BHOPAL' }));
expect(scoped.map((r) => r.deductee.name).sort()).toEqual(['Sunita', 'Vitthal']);
expect(scoped.find((r) => r.deductee.name === 'Sunita')!.schedule.totalTds).toBe(24_000);
it("narrows to a branch without changing anyone's figures", () => {
const all = tdsInterestRows(filter());
const branch = all[0].branchCode;
const scoped = tdsInterestRows(filter({ branchCode: branch }));
expect(scoped.length).toBeGreaterThan(0);
expect(scoped.every((r) => r.branchCode === branch)).toBe(true);
// A branch view must not re-aggregate — the threshold is computed bank-wide.
const sample = scoped[0];
const sameRow = all.find((r) => r.deductee.customerId === sample.deductee.customerId);
expect(sample.tdsApplicableInterest).toBe(sameRow?.tdsApplicableInterest);
});
it('finds the customers near the limit and those over it', () => {
expect(tdsInterestRows(filter({ nearThresholdOnly: true })).map((r) => r.deductee.name)).toEqual(['Vitthal']);
expect(
tdsInterestRows(filter({ crossedOnly: true }))
.map((r) => r.deductee.name)
.sort()
).toEqual(['Kamala', 'Ramesh', 'Sunita']);
it('finds the customers worth chasing a declaration from', () => {
const near = tdsInterestRows(filter({ nearThresholdOnly: true }));
expect(near.length).toBeGreaterThan(0);
for (const row of near) {
expect(row.crossed).toBe(false);
expect(row.headroom).toBeGreaterThan(0);
expect(row.headroom).toBeLessThanOrEqual(NEAR_THRESHOLD_BAND);
}
});
it('searches on name, customer ID and PAN', () => {
expect(tdsInterestRows(filter({ search: 'C3' })).length).toBe(1);
expect(tdsInterestRows(filter({ search: 'abcpk9012m' }))[0].deductee.name).toBe('Kamala');
it('narrows to customers who crossed', () => {
const crossed = tdsInterestRows(filter({ crossedOnly: true }));
expect(crossed.length).toBeGreaterThan(0);
expect(crossed.every((r) => r.crossed && r.tdsApplicableInterest > r.threshold)).toBe(true);
});
it('searches on name, CIF and PAN', () => {
const target = tdsInterestRowsForFy(CLOSED_FY).find((r) => r.panOnRecord)!;
expect(tdsInterestRows(filter({ search: target.deductee.customerId })).length).toBe(1);
expect(tdsInterestRows(filter({ search: target.deductee.pan })).length).toBeGreaterThanOrEqual(1);
});
});
describe('remittance', () => {
const challan = (over: Partial<TdsChallan>): TdsChallan => ({
id: 1,
fy: FY,
month: '2026-03',
amount: 0,
challanNo: '00001',
bsrCode: '0510308',
depositedOn: '2026-04-20',
paidFrom: '',
remarks: null,
reversed: false,
...over
describe('declarations', () => {
it('save tax and record the interest paid gross', () => {
const declared = tdsInterestRowsForFy(CLOSED_FY).filter((r) => r.hasDeclaration);
expect(declared.length).toBeGreaterThan(0);
expect(declared.some((r) => r.tdsSaved > 0)).toBe(true);
// Interest paid without deduction is a mandatory 26Q disclosure, so it must
// be carried even though no tax came off it.
expect(declared.some((r) => r.paidWithoutDeduction > 0)).toBe(true);
for (const row of declared) {
expect(row.declarationUin).toBeTruthy();
}
});
it('pays by the seventh of the following month, and 30 April for March', () => {
it('leaves tax deducted before a mid-year declaration in place', () => {
// Some declarations in the seed were accepted in October. Tax already taken
// in Q1/Q2 cannot be reversed, so those rows still carry a deduction.
const midYear = tdsInterestRowsForFy(CLOSED_FY).filter(
(r) => r.hasDeclaration && r.declarationAcceptedOn && r.declarationAcceptedOn.slice(5, 7) === '10'
);
expect(midYear.length).toBeGreaterThan(0);
expect(midYear.some((r) => r.schedule.totalTds > 0)).toBe(true);
});
});
describe('remittance schedule', () => {
it('deposits by the seventh of the following month, and 30 April for March', () => {
expect(depositDueDate('2025-06')).toBe('2025-07-07');
expect(depositDueDate('2025-12')).toBe('2026-01-07');
expect(depositDueDate('2026-03')).toBe('2026-04-30');
});
it('marks a month paid in full and on time', () => {
const [r] = tdsRemittanceRows(FY, [{ month: '2026-03', amount: 26_000, postings: 2 }], [challan({ amount: 26_000 })], '2026-06-01');
expect(r.quarter).toBe(4);
expect(r.outstanding).toBe(0);
expect(r.late).toBe(false);
expect(r.overdue).toBe(false);
expect(r.defaultInterest).toBe(0);
it('remits exactly what the engine says was deducted', () => {
const deducted = deductedByQuarter(CLOSED_FY);
const rows = tdsRemittanceRows(CLOSED_FY, TDS_AS_AT);
for (const row of rows) {
expect(row.amount).toBe(Math.round(deducted[row.quarter]));
}
});
it('charges interest on a late challan', () => {
const [r] = tdsRemittanceRows(
FY,
[{ month: '2026-03', amount: 26_000, postings: 2 }],
[challan({ amount: 26_000, depositedOn: '2026-05-02' })],
'2026-06-01'
);
expect(r.late).toBe(true);
expect(r.delayMonths).toBeGreaterThan(0);
expect(r.defaultInterest).toBeGreaterThan(0);
it('charges section 201(1A) interest on a late deposit and nothing on a timely one', () => {
const rows = tdsRemittanceRows(CLOSED_FY, TDS_AS_AT);
const late = rows.filter((r) => r.late);
expect(late.length).toBeGreaterThan(0);
for (const row of late) {
expect(row.delayMonths).toBeGreaterThan(0);
expect(row.defaultInterest).toBe(Math.round(row.amount * 0.015 * row.delayMonths));
}
for (const row of rows.filter((r) => !r.late && !r.overdue)) {
expect(row.defaultInterest).toBe(0);
}
});
});
it('flags an unpaid month past its due date, and not one still within it', () => {
const rows = tdsRemittanceRows(
FY,
[
{ month: '2025-12', amount: 5_000, postings: 1 },
{ month: '2026-03', amount: 7_000, postings: 1 }
],
[],
'2026-04-10'
);
const dec = rows.find((r) => r.month === '2025-12')!;
const mar = rows.find((r) => r.month === '2026-03')!;
expect(dec.overdue).toBe(true);
expect(dec.defaultInterest).toBeGreaterThan(0);
expect(mar.overdue).toBe(false);
expect(mar.outstanding).toBe(7_000);
describe('the 26Q reconciliation control', () => {
it.each(tdsFys())('balances for FY %s', (fy) => {
const s = tds26qSummary(fy, TDS_AS_AT);
// Deducted must equal deposited plus what is still within its due date.
// Anything else means tax has gone missing between the ledger and the
// Government, and the return must not be filed until it is found.
expect(s.difference).toBe(0);
expect(s.totalDeducted).toBe(s.totalDeposited + s.pendingDeposit);
});
it('splits the tax between the standard and the no-PAN rate without loss', () => {
const s = tds26qSummary(CLOSED_FY, TDS_AS_AT);
expect(s.tdsAtStandardRate + s.tdsAtHigherRate).toBe(s.totalDeducted);
expect(s.tdsAtHigherRate).toBeGreaterThan(0);
});
it('ignores a reversed challan', () => {
const [r] = tdsRemittanceRows(
FY,
[{ month: '2026-03', amount: 26_000, postings: 2 }],
[challan({ amount: 26_000, reversed: true })],
'2026-04-10'
);
expect(r.deposited).toBe(0);
expect(r.outstanding).toBe(26_000);
it('splits interest into the deducted and the paid-gross halves', () => {
const s = tds26qSummary(CLOSED_FY, TDS_AS_AT);
expect(s.interestWithDeduction + s.interestWithoutDeduction).toBe(s.totalInterestPaid);
// The gross-paid half is the Form 121 disclosure and must not be zero here,
// or the report would never exercise that line.
expect(s.interestWithoutDeduction).toBeGreaterThan(0);
});
it('reconciles deducted against paid plus still-within-due', () => {
const asAt = '2026-04-10';
const rows = tdsRemittanceRows(
FY,
[
{ month: '2025-12', amount: 5_000, postings: 1 },
{ month: '2026-03', amount: 21_000, postings: 1 }
],
[challan({ month: '2025-12', amount: 5_000, depositedOn: '2026-01-05' })],
asAt
);
const s = tds26qSummary(FY, rows, asAt);
expect(s.totalDeducted).toBe(26_000);
expect(s.totalDeposited).toBe(5_000);
expect(s.pendingDeposit).toBe(21_000);
expect(s.difference).toBe(0);
// The year's figures come from the same book as the interest report.
expect(s.calculatedTds).toBe(34_000);
expect(s.tdsAtStandardRate + s.tdsAtHigherRate).toBe(26_000);
it('agrees with the interest report on the same year', () => {
const summary = tdsInterestSummary(filter());
expect(tds26qSummary(CLOSED_FY, TDS_AS_AT).totalDeducted).toBe(summary.totalTds);
});
});
describe('the part-run current year', () => {
it('has postings only up to the business date', () => {
for (const deductee of tdsDeductees()) {
for (const account of deductee.accounts) {
for (const credit of account.credits) {
expect(credit.date <= TDS_AS_AT).toBe(true);
}
}
}
});
it('shows the closed quarter as settled and future obligations as not yet due', () => {
const rows = tdsRemittanceRows('2026-27', TDS_AS_AT);
// Q1 (Apr–Jun) has closed, so its tax was deducted and is due by 07 Jul.
expect(rows.some((r) => r.quarter === 1)).toBe(true);
// Q3 cannot appear: no interest has posted there and nothing settles there.
expect(rows.some((r) => r.quarter === 3)).toBe(false);
// Q4 *can* appear even in a part-run year — deposits on annual interest
// posting settle once, at year end — but that is a future obligation, so it
// must read as pending and must never be flagged overdue.
for (const row of rows.filter((r) => r.quarter === 4)) {
expect(row.depositedOn).toBeNull();
expect(row.overdue).toBe(false);
expect(row.defaultInterest).toBe(0);
}
});
});
+53 -142
View File
@@ -17,6 +17,7 @@
import {
DeclarationForm,
FY_TAX_CONFIG,
Quarter,
TdsMode,
TdsSchedule,
@@ -35,15 +36,7 @@ import {
tdsDeducteeOf,
tdsModeFor
} from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import {
TdsChallan,
TdsDeductedMonth,
TdsDeductee,
TdsReportAccount,
depositDueDate,
tdsDeductees,
tdsDeducteesVersion
} from './tds-reports-data';
import { TdsChallan, TdsDeductee, tdsChallans, tdsDeductees, tdsDeducteesVersion } from './tds-reports-data';
// ─────────────────────────────── Filtering ───────────────────────────────
@@ -80,10 +73,6 @@ export interface TdsAccountLine {
/** How many interest postings made up that figure. */
postings: number;
lastPostedOn: string | null;
/** TDS the platform actually withheld from this account in the year. */
tdsDeducted: number;
/** Set when the tax is deducted against someone other than the account's own holder. */
holderName: string | null;
}
/** How close to the trigger counts as "worth chasing a declaration for". */
@@ -135,11 +124,6 @@ export interface TdsInterestRow {
accounts: TdsAccountLine[];
/** How many of them count toward the deduction. */
trackedAccounts: number;
/**
* TDS the platform actually withheld this year. `schedule.totalTds` is what this report works
* out is due; the two should agree, and a difference is the first thing to investigate.
*/
actualTds: number;
}
function emptyQuarters(): Record<Quarter, number> {
@@ -208,9 +192,8 @@ export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] {
reason = 'Joint deposit — counted under the first-named holder';
} else {
tracked = true;
reason = 'Its interest received in savings counts toward the TDS limit';
reason = 'Counted toward the TDS limit';
}
const report = account as TdsReportAccount;
accountLines.push({
accountNo: account.accountNo || '',
type,
@@ -218,10 +201,7 @@ export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] {
reason,
interest: inYear,
postings: yearCredits.length,
lastPostedOn: yearCredits.reduce<string | null>((last, c) => (!last || c.date > last ? c.date : last), null),
tdsDeducted: (report.deductions || []).filter((c) => fyOf(c.date) === fy).reduce((s, c) => s + c.amount, 0),
holderName:
report.holderClientId && report.holderClientId !== deductee.clientId ? (report.holderName ?? null) : null
lastPostedOn: yearCredits.reduce<string | null>((last, c) => (!last || c.date > last ? c.date : last), null)
});
}
// Tracked accounts first, so the deposits that drive the figure lead the list.
@@ -256,8 +236,7 @@ export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] {
panOnRecord: isWellFormedPan(deductee.pan),
rateApplied: schedule.rateApplied,
accounts: accountLines,
trackedAccounts: accountLines.filter((a) => a.tracked).length,
actualTds: accountLines.reduce((s, a) => s + a.tdsDeducted, 0)
trackedAccounts: accountLines.filter((a) => a.tracked).length
});
}
@@ -334,8 +313,6 @@ export interface TdsInterestSummary {
/** Savings interest, outside the section. */
exemptInterest: number;
totalTds: number;
/** TDS the platform actually withheld — set against `totalTds`, which is what is due. */
actualTds: number;
crossed: number;
nearThreshold: number;
declarations: number;
@@ -365,7 +342,6 @@ export function tdsInterestSummary(filter: TdsReportFilter): TdsInterestSummary
applicableInterest: rows.reduce((s, r) => s + r.tdsApplicableInterest, 0),
exemptInterest: rows.reduce((s, r) => s + r.exemptInterest, 0),
totalTds: rows.reduce((s, r) => s + r.schedule.totalTds, 0),
actualTds: rows.reduce((s, r) => s + r.actualTds, 0),
crossed: rows.filter((r) => r.crossed).length,
nearThreshold: rows.filter((r) => r.nearThreshold).length,
declarations: rows.filter((r) => r.hasDeclaration).length,
@@ -381,34 +357,14 @@ export function tdsInterestSummary(filter: TdsReportFilter): TdsInterestSummary
// ─────────────────────── Report 2: remittance ───────────────────────
/**
* One deduction month: what the platform withheld, and what Head Office remitted against it.
*
* Driven by the withhold-tax postings and the challans entered, never by this report's own
* computation — a return reconciles what was deducted, not what should have been.
*/
export interface TdsRemittanceRow {
fy: string;
quarter: Quarter;
/** Month the tax was deducted in, e.g. '2026-06'. */
month: string;
deducted: number;
postings: number;
/** Live challans entered against this month. */
challans: TdsChallan[];
deposited: number;
lastDepositedOn: string | null;
dueDate: string;
/** Deducted but not yet remitted. */
outstanding: number;
/** Remitted beyond what was deducted — a challan entered against the wrong month, usually. */
excess: number;
/** Something is still outstanding and the due date has passed. */
export interface TdsRemittanceRow extends TdsChallan {
/** Statutory due date has passed with nothing deposited. */
overdue: boolean;
/** A challan reached the Government after the due date. */
/** Deposited, but after the due date. */
late: boolean;
/** Months or part months of delay. */
delayMonths: number;
/** Interest the bank owes for depositing late. */
/** Interest charged for depositing late. */
defaultInterest: number;
/** Return the quarter's deduction is reported in. */
returnDueDate: string;
@@ -421,78 +377,41 @@ const RETURN_DUE: Record<Quarter, string> = {
4: '31 May'
};
export function quarterOfMonth(month: string): Quarter {
const m = Number(month.slice(5, 7));
if (m >= 4 && m <= 6) {
return 1;
}
if (m >= 7 && m <= 9) {
return 2;
}
if (m >= 10) {
return 3;
}
return 4;
}
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
export function tdsRemittanceRows(
fy: string,
deducted: TdsDeductedMonth[],
challans: TdsChallan[],
asAt: string
): TdsRemittanceRow[] {
const live = challans.filter((c) => !c.reversed && c.fy === fy);
const months = [...new Set([...deducted.map((d) => d.month), ...live.map((c) => c.month)])].sort();
return months.map((month) => {
const d = deducted.find((x) => x.month === month);
const amount = d?.amount ?? 0;
const forMonth = live.filter((c) => c.month === month);
const deposited = round2(forMonth.reduce((s, c) => s + c.amount, 0));
const lastDepositedOn = forMonth.reduce<string | null>(
(last, c) => (!last || c.depositedOn > last ? c.depositedOn : last),
null
);
const dueDate = depositDueDate(month);
const outstanding = Math.max(0, round2(amount - deposited));
const excess = Math.max(0, round2(deposited - amount));
const overdue = outstanding > 0 && dueDate < asAt;
const lateChallans = forMonth.filter((c) => c.depositedOn > dueDate);
const late = lateChallans.length > 0;
// Interest runs per challan from the due date to the day it was paid, and on whatever is still
// outstanding from the due date to today.
let defaultInterest = lateChallans.reduce(
(s, c) => s + lateDeductionInterest(c.amount, dueDate, c.depositedOn, 'DEPOSIT', fy).interest,
0
);
if (overdue) {
defaultInterest += lateDeductionInterest(outstanding, dueDate, asAt, 'DEPOSIT', fy).interest;
/** Tax deducted per quarter, from the engine, for one year. */
export function deductedByQuarter(fy: string): Record<number, number> {
const out: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0 };
for (const row of tdsInterestRowsForFy(fy)) {
for (const line of row.schedule.lines) {
out[line.quarter] += line.tds;
}
const settledOn = overdue ? asAt : lastDepositedOn;
const delayMonths = (late || overdue) && settledOn ? monthsOrPartBetween(dueDate, settledOn) : 0;
}
return out;
}
export function tdsRemittanceRows(fy: string, asAt: string): TdsRemittanceRow[] {
const deducted: Record<string, Record<number, number>> = {};
for (const year of Object.keys(FY_TAX_CONFIG)) {
deducted[year] = deductedByQuarter(year);
}
const challans = tdsChallans(deducted)[fy] ?? [];
return challans.map((c) => {
const overdue = !c.depositedOn && c.dueDate < asAt;
const late = !!c.depositedOn && c.depositedOn > c.dueDate;
// While a payment is overdue the clock runs to today; once paid it stops on
// the payment date.
const settledOn = c.depositedOn ?? asAt;
const delayMonths = late || overdue ? monthsOrPartBetween(c.dueDate, settledOn) : 0;
const defaultInterest =
late || overdue ? lateDeductionInterest(c.amount, c.dueDate, settledOn, 'DEPOSIT', fy).interest : 0;
return {
fy,
quarter: quarterOfMonth(month),
month,
deducted: amount,
postings: d?.postings ?? 0,
challans: forMonth,
deposited,
lastDepositedOn,
dueDate,
outstanding,
excess,
...c,
overdue,
late,
delayMonths,
defaultInterest,
returnDueDate: RETURN_DUE[quarterOfMonth(month)]
returnDueDate: RETURN_DUE[c.quarter]
};
});
}
@@ -500,9 +419,9 @@ export function tdsRemittanceRows(
/**
* The year-end position — what the quarterly TDS return has to reconcile to.
*
* `difference` is the control figure: tax actually deducted, less what reached the Government,
* less what is still within its due date. Anything other than zero means a month is overdue or
* a challan sits against the wrong month.
* `difference` is the control figure: tax the engine says was deducted, less what
* actually reached the Government. Anything other than zero, once the pending
* challans are settled, means something is wrong and must be found before filing.
*/
export interface Tds26qSummary {
fy: string;
@@ -513,13 +432,9 @@ export interface Tds26qSummary {
interestWithoutDeduction: number;
tdsAtStandardRate: number;
tdsAtHigherRate: number;
/** What this report works out was due. */
calculatedTds: number;
/** What the platform actually withheld from accounts. */
totalDeducted: number;
totalDeposited: number;
pendingDeposit: number;
overdueAmount: number;
/** Deducted less deposited less still-pending. Must be zero. */
difference: number;
defaultInterest: number;
@@ -527,33 +442,29 @@ export interface Tds26qSummary {
overdueChallans: number;
}
export function tds26qSummary(fy: string, remittance: TdsRemittanceRow[], asAt: string): Tds26qSummary {
export function tds26qSummary(fy: string, asAt: string): Tds26qSummary {
const rows = tdsInterestRowsForFy(fy);
const challans = tdsRemittanceRows(fy, asAt);
const standard = rows.filter((r) => r.panOnRecord);
const higher = rows.filter((r) => !r.panOnRecord);
const totalDeducted = round2(remittance.reduce((s, r) => s + r.deducted, 0));
const totalDeposited = round2(remittance.reduce((s, r) => s + r.deposited, 0));
const pendingDeposit = round2(
remittance.filter((r) => r.dueDate >= asAt).reduce((s, r) => s + r.outstanding, 0)
);
const overdueAmount = round2(remittance.filter((r) => r.overdue).reduce((s, r) => s + r.outstanding, 0));
const totalDeducted = rows.reduce((s, r) => s + r.schedule.totalTds, 0);
const totalDeposited = challans.filter((c) => c.depositedOn).reduce((s, c) => s + c.amount, 0);
const pendingDeposit = challans.filter((c) => !c.depositedOn).reduce((s, c) => s + c.amount, 0);
return {
fy,
deductees: rows.filter((r) => r.trackedAccounts > 0).length,
deductees: rows.length,
totalInterestPaid: rows.reduce((s, r) => s + r.tdsApplicableInterest, 0),
interestWithDeduction: rows.reduce((s, r) => s + r.schedule.liableInterest, 0),
interestWithoutDeduction: rows.reduce((s, r) => s + r.paidWithoutDeduction, 0),
tdsAtStandardRate: standard.reduce((s, r) => s + r.actualTds, 0),
tdsAtHigherRate: higher.reduce((s, r) => s + r.actualTds, 0),
calculatedTds: rows.reduce((s, r) => s + r.schedule.totalTds, 0),
tdsAtStandardRate: standard.reduce((s, r) => s + r.schedule.totalTds, 0),
tdsAtHigherRate: higher.reduce((s, r) => s + r.schedule.totalTds, 0),
totalDeducted,
totalDeposited,
pendingDeposit,
overdueAmount,
difference: round2(totalDeducted - totalDeposited - pendingDeposit),
defaultInterest: remittance.reduce((s, r) => s + r.defaultInterest, 0),
lateChallans: remittance.filter((r) => r.late).length,
overdueChallans: remittance.filter((r) => r.overdue).length
difference: Math.round(totalDeducted - totalDeposited - pendingDeposit),
defaultInterest: challans.reduce((s, c) => s + c.defaultInterest, 0),
lateChallans: challans.filter((c) => c.late).length,
overdueChallans: challans.filter((c) => c.overdue).length
};
}
+33 -141
View File
@@ -13,52 +13,16 @@ import { Observable, map } from 'rxjs';
/** Custom Helpers */
import { toIsoDate } from '../core/utils/api-date';
import { Declaration, formKindFor } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { TdsChallan, TdsDeductedMonth, TdsDeductee } from './tds-reports-data';
/** A declaration as recorded on the platform. */
export interface TdsDeclarationRecord {
id: number;
clientId: number;
clientName?: string;
fy: string;
formType: '15G' | '15H' | '121';
uin: string | null;
acceptedOn: string;
estimatedIncome: number | null;
revoked: boolean;
revokedOn: string | null;
revokeReason: string | null;
}
export interface NewTdsDeclaration {
clientId: number;
fy: string;
formType: string;
acceptedOn: string;
uin?: string;
estimatedIncome?: number | null;
}
export interface NewTdsChallan {
fy: string;
month: string;
amount: number;
challanNo: string;
bsrCode: string;
depositedOn: string;
paidFromGlAccountId: number;
remarks?: string;
}
import { configuredFys } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { TdsDeductee } from './tds-reports-data';
/**
* Deposit interest, declarations and challans for the TDS screens.
* Deposit interest for the TDS reports.
*
* On an FD or RD the platform returns the interest that reached the customer's savings account and
* the TDS debited there for it — TDS is tracked and taken where the interest is received. A deposit
* is the customer's own money arriving and a fee is a charge, so neither is income and neither is
* sent. Working the figure out here from a balance and a rate would produce a number the passbook
* does not show.
* The platform returns the interest it actually credited — nothing else. A deposit is the
* customer's own money arriving and a fee is a charge, so neither is income and neither is sent.
* Working the figure out here from a balance and a rate would produce a number the passbook does
* not show.
*/
@Injectable({ providedIn: 'root' })
export class TdsReportsService {
@@ -68,129 +32,57 @@ export class TdsReportsService {
* @param financialYear the opening calendar year — 2026 means 1 Apr 2026 to 31 Mar 2027.
* Omit to let the platform use the year the business date falls in.
*/
getDepositInterest(financialYear?: number, clientId?: number): Observable<TdsDeductee[]> {
let params = new HttpParams();
if (financialYear) {
params = params.set('financialYear', String(financialYear));
}
if (clientId) {
params = params.set('clientId', String(clientId));
}
getDepositInterest(financialYear?: number): Observable<TdsDeductee[]> {
const params = financialYear ? new HttpParams().set('financialYear', String(financialYear)) : undefined;
return this.http
.get<any[]>('/tds/deposit-interest', { params })
.pipe(map((rows) => (rows || []).map((r) => this.toDeductee(r))));
}
getDeclarations(clientId: number): Observable<TdsDeclarationRecord[]> {
return this.http.get<TdsDeclarationRecord[]>('/tds/declarations', {
params: new HttpParams().set('clientId', String(clientId))
});
}
createDeclaration(declaration: NewTdsDeclaration): Observable<TdsDeclarationRecord[]> {
return this.http.post<TdsDeclarationRecord[]>('/tds/declarations', declaration);
}
revokeDeclaration(id: number, reason: string): Observable<TdsDeclarationRecord[]> {
return this.http.post<TdsDeclarationRecord[]>(`/tds/declarations/${id}/revoke`, { reason });
}
/** Tax actually withheld per month, and the business date the books are on. */
getDeducted(fy: string): Observable<{ asAt: string; months: TdsDeductedMonth[] }> {
return this.http.get<any>('/tds/deducted', { params: new HttpParams().set('fy', fy) }).pipe(
map((res) => ({
asAt: res?.asAt ?? '',
months: (res?.months || []).map((m: any) => ({
month: m.month,
amount: Number(m.amount) || 0,
postings: Number(m.postings) || 0
}))
}))
);
}
getChallans(fy: string): Observable<TdsChallan[]> {
return this.http
.get<any[]>('/tds/challans', { params: new HttpParams().set('fy', fy) })
.pipe(map((rows) => (rows || []).map((r) => this.toChallan(r))));
}
getChallanTemplate(): Observable<{ paidFromOptions: { id: number; glCode: string; name: string }[] }> {
return this.http.get<any>('/tds/challans/template');
}
createChallan(challan: NewTdsChallan): Observable<TdsChallan[]> {
return this.http.post<any[]>('/tds/challans', challan).pipe(map((rows) => (rows || []).map((r) => this.toChallan(r))));
}
reverseChallan(id: number): Observable<TdsChallan[]> {
return this.http
.post<any[]>(`/tds/challans/${id}/reverse`, {})
.pipe(map((rows) => (rows || []).map((r) => this.toChallan(r))));
}
private toChallan(r: any): TdsChallan {
return {
id: r.id,
fy: r.fy,
month: r.month,
amount: Number(r.amount) || 0,
challanNo: r.challanNo,
bsrCode: r.bsrCode,
depositedOn: (r.depositedOn || '').slice(0, 10),
paidFrom: r.paidFrom || '',
remarks: r.remarks ?? null,
reversed: !!r.reversed
};
}
/**
* Shapes one platform row into what the TDS computation expects.
* Shapes one platform row into what the section 194A computation expects.
*
* Dates arrive as [y, M, d] arrays and are flattened to ISO here, because the computation
* compares them as strings when deciding which financial year a credit falls in. The platform
* has already grouped a minor's deposit under the guardian and a joint deposit under its primary
* holder, so every account here belongs to the deductee it is listed under.
* compares them as strings when deciding which financial year a credit falls in.
*/
private toDeductee(row: any): TdsDeductee {
const dob = toIsoDate(row.dateOfBirth);
const credit = (a: any) => (c: any) => ({
date: toIsoDate(c.date),
amount: Number(c.amount) || 0,
accountNo: c.accountNo || a.accountNo || ''
});
return {
clientId: row.clientId,
customerId: row.customerId || '',
name: row.name || '',
pan: row.pan || '',
dob,
dob: toIsoDate(row.dateOfBirth),
branchCode: row.branchName || '',
declarations: this.declarationsOf(row.declarations || [], dob),
// A 15G/15H on the customer's KYC record covers the year it was accepted for. The platform
// carries the kind but not the acceptance date, so the year's start stands in — a
// declaration is operative for the year it was given, which is what the computation needs.
declarations: this.declarationsOf(row),
accounts: (row.accounts || []).map((a: any) => ({
id: a.id,
accountNo: a.accountNo || '',
depositTypeCode: a.depositTypeCode || '',
// The deductee, not the holder: the grouping above already resolved whose PAN it is.
accountStatus: a.accountStatus || undefined,
clientId: a.clientId ?? row.clientId,
holderClientId: a.holderClientId ?? undefined,
holderName: a.holderName ?? undefined,
credits: (a.credits || []).map(credit(a)),
deductions: (a.deductions || []).map(credit(a))
credits: (a.credits || []).map((c: any) => ({
date: toIsoDate(c.date),
amount: Number(c.amount) || 0,
accountNo: c.accountNo || a.accountNo || ''
}))
}))
};
}
/**
* Declarations keyed by year, the form the computation looks them up by ('2026-27'). A Form 121
* carries no G/H split, so the band follows the customer's age in that year.
*/
private declarationsOf(rows: any[], dob: string): Record<string, Declaration> {
const out: Record<string, Declaration> = {};
for (const d of rows) {
const fy = d.fy;
const kind = d.formType === '15H' ? '15H' : d.formType === '15G' ? '15G' : formKindFor(dob, fy);
out[fy] = { kind, acceptedOn: toIsoDate(d.acceptedOn), fy, uin: d.uin || undefined };
private declarationsOf(row: any): Record<string, any> {
const kind = (row.form15Type || '').trim().toUpperCase();
if (kind !== '15G' && kind !== '15H') {
return {};
}
const out: Record<string, any> = {};
// Applied to every configured year. Without an acceptance date on the record the declaration is
// treated as operative from the start of each year rather than mid-year. Keyed '2026-27', the
// form the computation looks it up by - an 'FY 2026-27' key was never found.
for (const fy of configuredFys()) {
out[fy] = { kind, acceptedOn: `${fy.slice(0, 4)}-04-01`, fy };
}
return out;
}
@@ -726,43 +726,3 @@
margin-top: 16px;
}
}
/* ── Challan form (remittance report) ── */
.tdsr__form {
padding-bottom: 14px;
}
.tdsr__filters--flat {
margin: 0;
border: 0;
box-shadow: none;
}
.tdsr__form-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
padding: 0 16px;
}
.tdsr__btn--primary {
border-color: var(--tdsr-blue);
background: var(--tdsr-blue);
color: #fff;
}
.tdsr__btn--sm {
padding: 3px 10px;
font-size: 11.5px;
}
.tdsr__link {
margin-left: 6px;
padding: 0;
border: 0;
background: none;
color: var(--tdsr-amber);
font: inherit;
text-decoration: underline;
cursor: pointer;
}
+2 -1
View File
@@ -32,6 +32,7 @@ import {
quarterEnd,
quarterOf
} from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { TDS_AS_AT } from './tds-reports-data';
import { TdsInterestRow, tdsInterestRowsForFy } from './tds-reports.compute';
/** The bank's own deductor particulars, which head every filing. */
@@ -130,7 +131,7 @@ function monthLabel(key: string): string {
* the portal rejects the line, and a silently dropped row is how a bank ends up
* reporting fewer declarations than it holds.
*/
export function declarationStatement(fy: string, asAt: string): DeclarationStatement {
export function declarationStatement(fy: string, asAt: string = TDS_AS_AT): DeclarationStatement {
const year = normaliseFy(fy);
const cfg = FY_TAX_CONFIG[year];
if (!cfg) {
+1
View File
@@ -7,3 +7,4 @@
-->
<router-outlet></router-outlet>
<mifosx-loading-screen></mifosx-loading-screen>
+5 -36
View File
@@ -8,7 +8,7 @@
/* eslint-disable @angular-eslint/prefer-inject */
/** Angular Imports */
import { Component, OnInit, HostListener, HostBinding, OnDestroy, inject } from '@angular/core';
import { Component, OnInit, HostListener, HostBinding, OnDestroy } from '@angular/core';
import { Router, NavigationEnd, ActivatedRoute } from '@angular/router';
import { Title } from '@angular/platform-browser';
import { MatSnackBar } from '@angular/material/snack-bar';
@@ -30,7 +30,6 @@ import { ThemeStorageService } from './shared/theme-picker/theme-storage.service
import { AlertService } from './core/alert/alert.service';
import { AuthenticationService } from './core/authentication/authentication.service';
import { SettingsService } from './settings/settings.service';
import { SystemService } from './system/system.service';
import { DocumentationLinksService } from 'app/shared/services/documentation-links.service';
import { IdleTimeoutService } from './home/timeout-dialog/idle-timeout.service';
import { SessionTimeoutDialogComponent } from './home/timeout-dialog/session-timeout-dialog.component';
@@ -103,7 +102,6 @@ export class WebAppComponent implements OnInit, OnDestroy {
private authSubscription: Subscription;
private destroy$ = new Subject<void>();
private systemService = inject(SystemService);
/**
* @param {Router} router Router for navigation.
@@ -256,27 +254,6 @@ export class WebAppComponent implements OnInit, OnDestroy {
}
// Set default max date picker as Today
this.settingsService.setBusinessDate(this.dateUtils.formatDate(new Date(), SettingsService.businessDateFormat));
// ...then replace it with the bank's own "today" (business date when enabled, server date when
// not) once signed in, and again whenever the business date or its on/off switch changes.
// isAuthenticated$ fires at the start of a fresh login, before the auth header is set - a call
// then gets a 401 Basic challenge and the browser's own sign-in box. So it only covers a restored
// session (credentials already saved); a fresh login is caught by 'Authentication Success'.
this.authenticationService.isAuthenticated$.pipe(takeUntil(this.destroy$)).subscribe((loggedIn) => {
if (loggedIn && this.authenticationService.isAuthenticated()) {
this.refreshBusinessToday();
}
});
this.alertService.alertEvent
.pipe(
filter(
(alertEvent: Alert) =>
alertEvent.type === 'Authentication Success' ||
alertEvent.type === SettingsService.businessDateType + ' Set' ||
alertEvent.type === SettingsService.businessDateType + ' Set Config'
),
takeUntil(this.destroy$)
)
.subscribe(() => this.refreshBusinessToday());
// Set the server list from the env var FINERACT_API_URLS, but avoid overwriting "Add new server" user choice
if (!this.settingsService.servers) {
this.settingsService.setServers(environment.baseApiUrls.split(','));
@@ -319,19 +296,11 @@ export class WebAppComponent implements OnInit, OnDestroy {
});
});
}
}
/** Stores the server's "today" where every date default reads it (see core/utils/business-today). */
private refreshBusinessToday(): void {
this.systemService.getBusinessToday().subscribe({
next: (res) => {
if (res?.today) {
this.settingsService.setBusinessDate(res.today);
}
},
// Unreachable server or older backend: keep whatever date is already stored.
error: () => {}
});
// Dismiss initial application boot splash screen smoothly
if (typeof (window as any).hideAppSplash === 'function') {
(window as any).hideAppSplash();
}
}
ngOnDestroy() {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -21,7 +21,7 @@
// in the tenants table of the tenant-store database, or the platform rejects the request as an
// unknown tenant.
window["env"]["fineractPlatformTenantId"] = 'utkarshbank';
window["env"]["fineractPlatformTenantIds"] = 'utkarshbank,lincpay';
window["env"]["fineractPlatformTenantIds"] = 'utkarshbank,lincpay,utkarshnagari,testbank,humarwork,default,mybank';
window['env']['tenantLogoUrl'] = '';
window['env']['tenantLogoUrlDark'] = '';
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+72 -30
View File
@@ -6,53 +6,95 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/* ==========================================================================
Modernized Loader Wrapper (Inspired by openMF/mifos-mobile)
Supports full-screen and embedded containers with smooth vector physics.
========================================================================== */
.loader-wrapper {
height: 100vh;
width: 100vw;
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 280px;
height: 100%;
background: transparent;
/* Modern orbital rings that replace legacy rigid triangles smoothly */
.triangle {
width: 0;
height: 0;
border-style: solid;
position: absolute;
animation: rotate 0.8s infinite 0.2s;
border-radius: 50%;
border-style: solid;
box-sizing: border-box;
transition: all 0.3s ease;
}
.bottom {
border-width: 0 90px 90px;
border-color: transparent transparent #1fb050;
bottom: calc(50% - 100px);
left: calc(50% - 90px);
width: 72px;
height: 72px;
border-width: 3px;
border-color: #2563eb transparent transparent;
animation: cbs-spin-ring 1.1s cubic-bezier(0.5, 0, 0.5, 1) infinite;
box-shadow: 0 0 16px rgb(37 99 235 / 35%);
}
.top {
border-width: 90px 90px 0;
border-color: #b4d575 transparent transparent;
top: calc(50% - 100px);
left: calc(50% - 90px);
width: 54px;
height: 54px;
border-width: 3px;
border-color: transparent transparent #0ea5e9;
animation: cbs-spin-ring 1.4s cubic-bezier(0.5, 0, 0.5, 1) infinite reverse;
box-shadow: 0 0 14px rgb(14 165 233 / 35%);
}
.left {
border-width: 90px 0 90px 90px;
border-color: transparent transparent transparent #1074b9;
top: calc(50% - 90px);
left: calc(50% - 100px);
width: 36px;
height: 36px;
border-width: 2.5px;
border-color: #10b981 transparent transparent;
animation: cbs-spin-ring 0.9s cubic-bezier(0.5, 0, 0.5, 1) infinite;
box-shadow: 0 0 10px rgb(16 185 129 / 35%);
}
.right {
border-width: 90px 90px 90px 0;
border-color: transparent #1daeec transparent transparent;
top: calc(50% - 90px);
right: calc(50% - 100px);
width: 18px;
height: 18px;
border-width: 2px;
border-color: transparent #38bdf8 transparent transparent;
animation: cbs-spin-ring 0.7s cubic-bezier(0.5, 0, 0.5, 1) infinite reverse;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
/* Optional central glowing core */
&::after {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background: #38bdf8;
box-shadow: 0 0 12px #38bdf8;
animation: cbs-pulse-core 1.5s ease-in-out infinite;
}
}
@keyframes cbs-spin-ring {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes cbs-pulse-core {
0%,
100% {
transform: scale(0.85);
opacity: 0.6;
}
50% {
transform: scale(1.3);
opacity: 1;
}
}
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '261006',
'hash': '5ab4e9ea'
'version': '261008',
'hash': '4acee3e63'
},
'allow_switching_backend_instance': true
};
+2 -1
View File
@@ -20,7 +20,8 @@ export const environment = {
hash: env.mifos_x.hash,
// For connecting to server running elsewhere update the tenant identifier
fineractPlatformTenantId: loadedEnv['fineractPlatformTenantId'] || 'utkarshbank',
fineractPlatformTenantIds: loadedEnv['fineractPlatformTenantIds'] || 'utkarshbank,lincpay',
fineractPlatformTenantIds:
loadedEnv['fineractPlatformTenantIds'] || 'utkarshbank,lincpay,utkarshnagari,testbank,humarwork,default,mybank',
// For connecting to others servers running elsewhere update the base API URL
baseApiUrls:
loadedEnv['fineractApiUrls'] || 'https://demo.mifos.community,https://localhost:8443,' + window.location.origin,
+2 -1
View File
@@ -23,7 +23,8 @@ export const environment = {
hash: env.mifos_x.hash,
// For connecting to server running elsewhere update the tenant identifier
fineractPlatformTenantId: loadedEnv.fineractPlatformTenantId || 'utkarshbank',
fineractPlatformTenantIds: loadedEnv.fineractPlatformTenantIds || 'utkarshbank,lincpay',
fineractPlatformTenantIds:
loadedEnv.fineractPlatformTenantIds || 'utkarshbank,lincpay,utkarshnagari,testbank,humarwork,default,mybank',
// For connecting to others servers running elsewhere update the base API URL
baseApiUrls:
loadedEnv.fineractApiUrls || 'https://demo.mifos.community,https://localhost:8443,' + window.location.origin,
+290 -9
View File
@@ -11,7 +11,7 @@
<html lang="en" class="h-full bg-gray-100">
<head>
<meta charset="utf-8" />
<title>{{'APP_NAME' | translate}}</title>
<title>CBS Nexora</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="apple-mobile-web-app-capable" content="yes" />
@@ -27,7 +27,10 @@
rel="stylesheet"
/>
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700|Roboto+Mono:300" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@500&display=swap" rel="stylesheet" />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Serif:wght@600&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700;800&family=Newsreader:ital,wght@0,400;0,500;1,400;1,500&display=swap"
rel="stylesheet"
@@ -108,13 +111,291 @@
});
</script>
<mifosx-web-app>
<div class="loader-wrapper">
<div class="bottom triangle"></div>
<div class="top triangle"></div>
<div class="left triangle"></div>
<div class="right triangle"></div>
<div id="cbs-app-splash-screen" class="cbs-init-screen">
<style>
.cbs-init-screen {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: radial-gradient(circle at center, #0f172a 0%, #090d16 100%);
color: #f8fafc;
font-family:
'Plus Jakarta Sans',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
z-index: 999999;
overflow: hidden;
transition:
opacity 0.6s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
.cbs-init-screen.cbs-splash-fade-out {
opacity: 0;
transform: scale(1.02);
pointer-events: none;
}
.cbs-init-brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
margin-bottom: 20px;
text-align: center;
}
.cbs-init-logo-wrap {
display: flex;
align-items: center;
justify-content: center;
}
.cbs-init-logo-img {
height: 52px;
width: auto;
max-width: 260px;
object-fit: contain;
filter: drop-shadow(0 6px 18px rgba(0, 0, 0, 0.5));
transition: transform 0.3s ease;
}
.cbs-init-icon {
width: 56px;
height: 56px;
border-radius: 14px;
background: linear-gradient(135deg, #1e3a8a 0%, #0284c7 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10px 24px rgba(14, 165, 233, 0.35);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.cbs-init-title {
font-size: 1.5rem;
font-weight: 800;
letter-spacing: -0.02em;
margin: 0;
}
.cbs-init-sub {
font-size: 0.78rem;
color: #94a3b8;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.cbs-init-lottie-box {
width: 130px;
height: 130px;
margin: 8px auto;
display: flex;
align-items: center;
justify-content: center;
filter: drop-shadow(0 8px 24px rgba(37, 99, 235, 0.25));
}
.cbs-init-spinner {
width: 64px;
height: 64px;
position: relative;
}
.cbs-init-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 3px solid transparent;
}
.cbs-init-ring-1 {
border-top-color: #2563eb;
border-right-color: #2563eb;
animation: cbsInitSpin 1.1s cubic-bezier(0.5, 0, 0.5, 1) infinite;
}
.cbs-init-ring-2 {
inset: 6px;
border-bottom-color: #0ea5e9;
border-left-color: #0ea5e9;
animation: cbsInitSpin 1.5s cubic-bezier(0.5, 0, 0.5, 1) infinite reverse;
}
.cbs-init-ring-3 {
inset: 12px;
border-top-color: #10b981;
animation: cbsInitSpin 0.9s cubic-bezier(0.5, 0, 0.5, 1) infinite;
}
.cbs-init-track {
width: 260px;
height: 5px;
background: rgba(255, 255, 255, 0.1);
border-radius: 99px;
overflow: hidden;
margin: 18px 0 12px;
position: relative;
}
.cbs-init-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #2563eb, #38bdf8);
border-radius: 99px;
box-shadow: 0 0 12px rgba(56, 189, 248, 0.6);
transition: width 0.3s ease;
}
.cbs-init-status {
font-size: 0.82rem;
color: #94a3b8;
font-family: inherit;
margin: 0;
height: 20px;
}
@keyframes cbsInitSpin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
<div class="cbs-init-brand">
<div class="cbs-init-logo-wrap">
<img
src="assets/images/cbsnexora-logo.png"
alt="CBS Nexora"
class="cbs-init-logo-img"
onerror="
this.style.display = 'none';
document.getElementById('cbs-init-fallback-brand').style.display = 'flex';
"
/>
<div id="cbs-init-fallback-brand" style="display: none; align-items: center; gap: 8px">
<div class="cbs-init-icon">
<svg width="32" height="32" viewBox="0 0 32 32">
<rect x="2" y="2" width="13" height="13" rx="2.5" fill="#ffffff" />
<rect x="17" y="2" width="13" height="13" rx="2.5" fill="#29abe2" />
<rect x="2" y="17" width="13" height="13" rx="2.5" fill="#00b388" />
<rect x="17" y="17" width="13" height="13" rx="2.5" fill="#6b7fa3" />
</svg>
</div>
<h2 class="cbs-init-title" style="margin: 0; font-size: 1.4rem">CBS Nexora</h2>
</div>
</div>
<span class="cbs-init-sub">Next-Generation Core Banking</span>
</div>
</mifosx-web-app>
<div id="cbs-init-lottie-target" class="cbs-init-lottie-box">
<div class="cbs-init-spinner">
<div class="cbs-init-ring cbs-init-ring-1"></div>
<div class="cbs-init-ring cbs-init-ring-2"></div>
<div class="cbs-init-ring cbs-init-ring-3"></div>
</div>
</div>
<div class="cbs-init-track">
<div class="cbs-init-fill" id="cbs-init-progress-bar"></div>
</div>
<p class="cbs-init-status" id="cbs-init-status-text">Connecting to Secure Banking Gateway...</p>
</div>
<script>
(function () {
var splash = document.getElementById('cbs-app-splash-screen');
var bar = document.getElementById('cbs-init-progress-bar');
var status = document.getElementById('cbs-init-status-text');
var progress = 0;
var minTimePassed = false;
var angularReady = false;
var dismissed = false;
// Load Lottie Vector Animation
var script = document.createElement('script');
script.src = 'assets/js/lottie.min.js';
script.onload = function () {
if (typeof lottie !== 'undefined') {
var target = document.getElementById('cbs-init-lottie-target');
if (target) {
target.innerHTML = '';
lottie.loadAnimation({
container: target,
renderer: 'svg',
loop: true,
autoplay: true,
path: 'assets/animations/loading_animation.json'
});
}
}
};
document.head.appendChild(script);
// Progress simulation stages (Guaranteed ~2 seconds duration on refresh)
var interval = setInterval(function () {
if (progress < 90) {
progress += 5;
} else if (angularReady && progress < 100) {
progress += 5;
}
if (bar) bar.style.width = Math.min(progress, 100) + '%';
if (status) {
if (progress >= 85) {
status.innerText = 'Launching CBS Nexora Dashboard...';
} else if (progress >= 60) {
status.innerText = 'Synchronizing core banking ledger schema...';
} else if (progress >= 30) {
status.innerText = 'Authenticating token & cryptographic keys...';
}
}
if (progress >= 100 && angularReady && minTimePassed) {
clearInterval(interval);
dismissSplash();
}
}, 90);
// Enforce minimum hold time (2000ms = 2 seconds) so refresh always shows the animation
setTimeout(function () {
minTimePassed = true;
if (angularReady) {
progress = 100;
if (bar) bar.style.width = '100%';
dismissSplash();
}
}, 2000);
function dismissSplash() {
if (dismissed) return;
dismissed = true;
clearInterval(interval);
if (bar) bar.style.width = '100%';
if (status) status.innerText = 'Ready! System loaded successfully.';
setTimeout(function () {
if (splash) {
splash.classList.add('cbs-splash-fade-out');
setTimeout(function () {
if (splash && splash.parentNode) {
splash.parentNode.removeChild(splash);
}
}, 600);
}
}, 250);
}
// Global hook for Angular to notify when ready
window.hideAppSplash = function () {
angularReady = true;
if (minTimePassed) {
dismissSplash();
}
};
// Safety fallback (Max 7s in case of backend network failure)
setTimeout(function () {
angularReady = true;
minTimePassed = true;
dismissSplash();
}, 7000);
})();
</script>
<mifosx-web-app></mifosx-web-app>
</body>
</html>
+343 -37
View File
@@ -81,66 +81,332 @@
// ===== Sub-menu Mega Panels (rendered in CDK overlay) =====
.deposits-mega-panel,
.hrm-mega-panel {
max-width: 700px !important;
.hrm-mega-panel,
.cashier-mega-panel,
.clerk-mega-panel,
.clearing-mega-panel,
.npa-mega-panel,
.advances-mega-panel,
.admin-mega-panel {
min-width: 680px !important;
max-width: 740px !important;
}
.branch-ops-mega-panel,
.insurance-mega-panel,
.digital-mega-panel,
.ckyc-mega-panel {
min-width: 440px !important;
max-width: 480px !important;
overflow-x: hidden !important;
}
.share-mega-panel,
.others-mega-panel {
max-width: 850px !important;
min-width: 780px !important;
max-width: 840px !important;
}
.share-grid,
.others-grid {
grid-template-columns: repeat(4, 1fr) !important;
}
.others-grid,
.npa-grid {
grid-template-columns: repeat(3, 1fr) !important;
}
.npa-mega-panel,
.advances-mega-panel {
.reports-mega-panel {
min-width: 740px !important;
max-width: 800px !important;
border-top: 2.5px solid #0369a1 !important;
}
.advances-grid {
grid-template-columns: repeat(3, 1fr) !important;
.letter-mega-panel,
.reconciliation-mega-panel {
min-width: 900px !important;
max-width: 960px !important;
}
/* ==========================================================================
Mega Menu Panel CDK Overlay Rules (Anchor naturally under triggers)
========================================================================== */
.cdk-overlay-pane:has(.mat-mdc-menu-panel) {
max-width: 96vw !important;
}
/* Individual Top Accent Border for Mega Panels */
.deposits-mega-panel,
.advances-mega-panel {
border-top: 2.5px solid #1074b9 !important;
}
.cashier-mega-panel {
border-top: 2.5px solid #0284c7 !important;
}
.clerk-mega-panel {
border-top: 2.5px solid #2563eb !important;
}
.branch-ops-mega-panel {
border-top: 2.5px solid #4f46e5 !important;
}
.clearing-mega-panel,
.reconciliation-mega-panel {
border-top: 2.5px solid #0d9488 !important;
}
.hrm-mega-panel {
border-top: 2.5px solid #0284c7 !important;
}
.admin-mega-panel,
.digital-mega-panel {
border-top: 2.5px solid #7c3aed !important;
}
.share-mega-panel {
border-top: 2.5px solid #d97706 !important;
}
.others-mega-panel {
border-top: 2.5px solid #0284c7 !important;
}
.npa-mega-panel {
border-top: 2.5px solid #dc2626 !important;
}
.letter-mega-panel {
max-width: 1100px !important;
border-top: 2.5px solid #15803d !important;
}
.letter-grid {
grid-template-columns: repeat(5, minmax(190px, 1fr)) !important;
min-width: 1040px;
.insurance-mega-panel {
border-top: 2.5px solid #c2410c !important;
}
.reconciliation-mega-panel {
max-width: 1100px !important;
.ckyc-mega-panel {
border-top: 2.5px solid #be185d !important;
}
/* Universal Mega Panel Grid styling inside Material Menu Overlays */
.deposit-menu-grid {
display: grid !important;
gap: 4px 12px !important;
padding: 6px 10px !important;
box-sizing: border-box !important;
min-width: 0 !important;
width: 100% !important;
max-width: 100% !important;
overflow-x: hidden !important;
}
/* Mega Panel Grids with exact column definitions */
.deposit-menu-grid.cashier-grid,
.deposit-menu-grid.clerk-grid,
.deposit-menu-grid.clearing-grid,
.deposit-menu-grid.advances-grid,
.deposit-menu-grid.deposits-grid,
.deposit-menu-grid.hrm-grid,
.deposit-menu-grid.admin-grid,
.deposit-menu-grid.npa-grid,
.cashier-grid,
.clerk-grid,
.clearing-grid,
.advances-grid,
.deposits-grid,
.hrm-grid,
.admin-grid,
.npa-grid {
grid-template-columns: repeat(3, minmax(210px, 1fr)) !important;
}
.deposit-menu-grid.reports-grid,
.reports-grid {
grid-template-columns: repeat(3, minmax(230px, 1fr)) !important;
}
.deposit-menu-grid.branch-ops-grid,
.deposit-menu-grid.insurance-grid,
.deposit-menu-grid.digital-grid,
.deposit-menu-grid.ckyc-grid,
.branch-ops-grid,
.insurance-grid,
.digital-grid,
.ckyc-grid {
grid-template-columns: repeat(2, minmax(200px, 1fr)) !important;
}
.deposit-menu-grid.share-grid,
.deposit-menu-grid.others-grid,
.share-grid,
.others-grid {
grid-template-columns: repeat(4, minmax(185px, 1fr)) !important;
}
.deposit-menu-grid.letter-grid,
.deposit-menu-grid.reconciliation-grid,
.letter-grid,
.reconciliation-grid {
grid-template-columns: repeat(5, minmax(190px, 1fr)) !important;
min-width: 1040px;
grid-template-columns: repeat(5, minmax(170px, 1fr)) !important;
}
/* Admin Mega Menu — 3-column rectangular panel (Setup · Catalog ·
Audit & Compliance). Reuses the deposit-mega-menu grid + group chrome
and retints the group-title accent to Admin purple. */
.admin-mega-panel {
max-width: 760px !important;
}
.admin-grid {
grid-template-columns: repeat(3, minmax(220px, 1fr)) !important;
min-width: 680px;
.deposit-menu-group {
display: flex !important;
flex-direction: column !important;
gap: 1px !important;
min-width: 0 !important;
.deposit-group-title {
color: #7c3aed !important;
border-bottom-color: #7c3aed !important;
font-weight: 700 !important;
font-size: 9.5px !important;
text-transform: uppercase !important;
letter-spacing: 0.6px !important;
color: #1074b9 !important;
padding: 3px 6px !important;
margin-bottom: 2px !important;
border-bottom: 1.5px solid #1074b9 !important;
display: flex !important;
align-items: center !important;
line-height: 1.2 !important;
white-space: nowrap !important;
}
}
/* Specific Accent Colors for Mega Panel Group Titles */
.cashier-grid .deposit-group-title {
color: #0284c7 !important;
border-bottom-color: #0284c7 !important;
}
.clerk-grid .deposit-group-title {
color: #2563eb !important;
border-bottom-color: #2563eb !important;
}
.clearing-grid .deposit-group-title {
color: #0d9488 !important;
border-bottom-color: #0d9488 !important;
}
.branch-ops-grid .deposit-group-title {
color: #4f46e5 !important;
border-bottom-color: #4f46e5 !important;
}
.reports-grid .deposit-group-title {
color: #0369a1 !important;
border-bottom-color: #0369a1 !important;
}
.insurance-grid .deposit-group-title {
color: #c2410c !important;
border-bottom-color: #c2410c !important;
}
.digital-grid .deposit-group-title {
color: #7c3aed !important;
border-bottom-color: #7c3aed !important;
}
.ckyc-grid .deposit-group-title {
color: #be185d !important;
border-bottom-color: #be185d !important;
}
.admin-grid .deposit-group-title {
color: #7c3aed !important;
border-bottom-color: #7c3aed !important;
}
.reconciliation-grid .deposit-group-title {
color: #0d9488 !important;
border-bottom-color: #0d9488 !important;
}
/* Compact, RBI-Style Menu Item Overrides in CDK Overlays */
/* Compact, RBI-Style Menu Item Overrides for Navbar Mega Panels ONLY */
.mat-mdc-menu-panel:has(.deposit-menu-grid) {
--mat-menu-item-label-text-size: 10.5px !important;
--mat-menu-item-label-text-line-height: 14px !important;
border-radius: 8px !important;
box-shadow:
0 10px 28px rgb(15 23 42 / 14%),
0 2px 6px rgb(15 23 42 / 8%) !important;
border: 1px solid #d1d5db !important;
background: #fff !important;
max-height: 75vh !important;
overflow: hidden auto !important;
.mat-mdc-menu-content {
padding: 4px 6px !important;
overflow-x: hidden !important;
}
.deposit-menu-grid .mat-mdc-menu-item {
font-size: 10.5px !important;
font-weight: 500 !important;
height: 25px !important;
min-height: 25px !important;
line-height: 25px !important;
padding: 0 6px !important;
border-radius: 4px !important;
color: #334155 !important;
font-family:
'IBM Plex Sans',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif !important;
transition:
background-color 0.12s ease,
color 0.12s ease !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
min-width: 0 !important;
width: 100% !important;
box-sizing: border-box !important;
.mat-mdc-menu-item-text,
.mdc-list-item__primary-text,
.mdc-list-item__content {
font-size: 10.5px !important;
font-weight: 500 !important;
line-height: 1.25 !important;
color: #334155 !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
display: inline-flex !important;
align-items: center !important;
width: 100% !important;
}
.menu-item-icon,
fa-icon,
i {
font-size: 10.5px !important;
width: 13px !important;
min-width: 13px !important;
text-align: center !important;
margin-right: 5px !important;
opacity: 0.8 !important;
flex-shrink: 0 !important;
}
&:hover {
background-color: #f0f7ff !important;
color: #0d47a1 !important;
.mat-mdc-menu-item-text,
.mdc-list-item__primary-text {
color: #0d47a1 !important;
}
.menu-item-icon,
fa-icon,
i {
opacity: 1 !important;
color: #0d47a1 !important;
}
}
}
}
@@ -2012,3 +2278,43 @@ mifosx-client-datatable-step * {
}
}
}
// ===== CBS Compact Enterprise Density Polish =====
// Reduces excessive whitespace and oversized elements across the banking UI
.mat-mdc-table {
th.mat-mdc-header-cell {
padding: 8px 12px !important;
font-size: 11px !important;
font-weight: 700 !important;
letter-spacing: 0.04em !important;
}
td.mat-mdc-cell {
padding: 6px 12px !important;
font-size: 12px !important;
line-height: 1.35 !important;
}
tr.mat-mdc-row,
tr.mat-mdc-header-row {
height: 36px !important;
min-height: 36px !important;
}
}
// Compact Material Cards
.mat-mdc-card {
padding: 12px 16px !important;
}
// Breadcrumb & Page Titles Compact Spacing
.breadcrumb-container {
margin-bottom: 8px !important;
padding: 4px 0 !important;
}
.mifosx-page-title,
.page-title {
font-size: 17px !important;
margin-bottom: 8px !important;
}
+1 -1
View File
@@ -53,7 +53,7 @@ $mifosx-typography: mat.m2-define-typography-config(
$button: $mifosx-typography-level
);
$mifosx-density: 0;
$mifosx-density: -1;
$mifosx-app-theme: mat.m2-define-light-theme(
(