Compare commits

...
Author SHA1 Message Date
niteshandClaude Opus 5.5 767461faae Merge branch 'feature/fd-interest-posting' into dev-latest
Brings the FD interest posting approval UI and the latest UI work into
dev-latest:
- FD interest posting approval screens, posting period and
  GL-reconciled maturity interest
- FD maturity approval UI added and then reverted (maturity now runs
  directly through the scheduler, no approval step)
- "mybank" tenant registered in the UI tenant list
- Animated boot splash and reusable loading screen
- Compact navigation with grouped mega menus and branded loaders

Conflict resolved:
- src/environments/.env.ts: kept dev-latest's generated version/hash;
  the file is regenerated by `npm start` anyway.

Also: prettier formatting of toolbar.component.html (already
unformatted on dev-latest; formatting only, required by the
pre-commit hook).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:20:00 +05:30
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
keshav c91dcf4d99 Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev-latest
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-10-12 10:23:26 +05:30
keshav 2d2eac8d03 feat: recurring deposit enhancements, dashboard, chart, and account view updates 2026-10-12 10:23:10 +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
nitesh 0976bc230c Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev-latest 2026-10-07 10:19:26 +05:30
keshav 6b0f218b70 Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev-latest 2026-10-07 10:13:37 +05:30
keshav 6226682b36 feat: updates for cashier, tds reports, rbi reserve and dashboard 2026-10-07 10:13:28 +05:30
niteshandClaude Opus 5 38884610d2 feat(shares): preview the approval checklist before submit, fix settlement GL option
The Clerk Dashboard's standalone Share Withdrawal Request form never offered a
GL settlement destination -- it always submitted SAVINGS, silently, while the
Cashier's Transaction Entry screen already let a clerk pick any postable GL
account on its CR row. Added the same "Settlement Destination" choice (Savings
/ GL Account) with a searchable GL selector here too, so both entry points to
the same backend endpoint behave the same way.

Both share-withdrawal confirm dialogs (savings settlement and GL settlement, in
transaction-entry.component.ts) now call the backend's new preview-validate
endpoint before showing the confirm dialog, so a clerk sees the same checklist
a manager will see on approval -- a missing GL mapping, an unverified KYC, an
open AML alert -- before committing to the request, not after a manager has
already spent time reviewing it. A failing check is shown, not blocked: real
hard rules are still enforced server-side at approve(). Guarded with a 5s
timeout and a fallback message so a slow or unavailable check can never leave
the dialog looking stuck.

error-handler.interceptor.ts: a document/signature attachment whose row
survives but whose file is missing from the content store now resolves to a
silent 404 (matching the existing image-fetch handling) instead of surfacing a
raw error. Background GET failures (500/501/503) on calls a component already
recovers from (photo, attachment, notes, standing instructions, ...) now
surface as a corner toast instead of a blocking modal that has to be dismissed
-- a write action's failure (POST/PUT/DELETE) still gets the centred,
must-acknowledge dialog, since that is the one thing the user was waiting on.
Also surfaces the backend's real message on a 500 instead of a hardcoded
generic sentence.

Backend counterpart: ClerkShareWithdrawalRequestApiResource (preview-validate
endpoint, shared buildShareWithdrawalChecks()) and
DocumentReadPlatformServiceImpl (missing-file 404) land separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-06 22:36:15 +05:30
niteshandClaude Sonnet 5 d6d7d912c0 fix(savings-product): guard against duplicate submission on create
The product name is unique in the database, so a repeat click on Submit
while the first request is still in flight fails with a duplicate key
error even though the first request already created the product. Disables
the button and ignores repeat calls until the request settles; also drops
a stray debug console.log from the submit payload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-06 12:39:43 +05:30
nitesh 454988d803 Merge remote-tracking branch 'origin/dev-latest' into dev-latest 2026-10-06 11:01:00 +05:30
niteshandClaude Sonnet 5 17bfe4c7e6 fix(tasks): separate Pending Share Accounts from the Pending Account screen
Removes the Pending Share Accounts section that had been bolted onto the
Pending Account screen (duplicate data/actions alongside the normal-account
table), restoring it as its own standalone queue. The standalone
pending-share-accounts route/component already existed but had no tab link
anywhere in the Approval Center nav, so it was unreachable from the UI -
added a "Pending Share Accounts" tab for it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-06 10:56:25 +05:30
keshav 5ab4e9eaf0 Merge remote-tracking branch 'origin/dev-latest' into dev-latest 2026-10-05 12:41:38 +05:30
keshav 4fc6784695 feat: interest posting 2026-10-05 12:39:14 +05:30
158 changed files with 18868 additions and 3192 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",
@@ -353,12 +353,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted From</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input
matInput
[matDatepicker]="dpSubFrom"
[min]="minDate"
[formControl]="submittedOnDateFrom"
/>
<input matInput [matDatepicker]="dpSubFrom" [min]="minDate" [formControl]="submittedOnDateFrom" />
<mat-datepicker-toggle matSuffix [for]="dpSubFrom"></mat-datepicker-toggle>
<mat-datepicker #dpSubFrom></mat-datepicker>
</mat-form-field>
+5
View File
@@ -85,6 +85,11 @@ export class AccountsService {
return this.http.get('/fixeddepositaccounts/maturity-dashboard');
}
/** RD Dashboard: running RDs with their installment position, closures and penalty income for the month. */
getRecurringDepositDashboard(): Observable<any> {
return this.http.get('/recurringdepositaccounts/dashboard');
}
getRecurringDepositAccounts(params: AccountListParams = {}): Observable<any> {
let httpParams = new HttpParams()
.set('paged', 'true')
+4
View File
@@ -64,6 +64,7 @@ import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
import { DeafModule } from './deaf/deaf.module';
import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module';
import { FdDashboardModule } from './fd-dashboard/fd-dashboard.module';
import { RdDashboardModule } from './rd-dashboard/rd-dashboard.module';
import { DicgcModule } from './dicgc/dicgc.module';
import { FiuModule } from './fiu/fiu.module';
import { GlReportsModule } from './gl-reports/gl-reports.module';
@@ -96,6 +97,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 {
@@ -178,6 +180,7 @@ export function HttpLoaderFactory(http: HttpClient) {
DeafModule,
SavingsDailyInterestModule,
FdDashboardModule,
RdDashboardModule,
DicgcModule,
FiuModule,
GlReportsModule,
@@ -195,6 +198,7 @@ export function HttpLoaderFactory(http: HttpClient) {
AppRoutingModule,
NotFoundComponent,
CallbackComponent,
LoadingScreenComponent,
OAuthModule.forRoot()
],
providers: [
@@ -12,6 +12,7 @@ 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;
@@ -83,7 +84,7 @@ export class AccountRegisterComponent implements OnInit {
/** Local date — toISOString() would shift the day in any timezone behind UTC. */
private today(): string {
const d = new Date();
const d = businessToday();
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,6 +11,7 @@ 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;
@@ -1316,7 +1317,7 @@ export class CashClearingTransferSummaryComponent {
private dateTag = '2607';
constructor() {
const d = new Date();
const d = businessToday();
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,6 +12,7 @@ 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;
@@ -63,7 +64,7 @@ export class CashExchangeEntryComponent implements OnInit {
}
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.exchangeDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
@@ -18,6 +18,7 @@ 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';
@@ -90,7 +91,7 @@ export class CashSettlementComponent implements OnInit {
saving = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.settlementDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
.getTellers()
@@ -17,6 +17,7 @@ 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;
@@ -82,7 +83,7 @@ export class CashTransferComponent implements OnInit {
loadingVault = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
@@ -17,6 +17,7 @@ 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;
@@ -71,7 +72,7 @@ export class CashierPositionComponent implements OnInit {
isCashierMode = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.isCashierMode = this.authService.isCashierUser();
@@ -51,6 +51,12 @@ 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,6 +12,7 @@ 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({
@@ -204,7 +205,7 @@ export class ClosingCashReportComponent implements OnInit {
}
ngOnInit(): void {
const d = new Date();
const d = businessToday();
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,6 +13,7 @@ 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.
@@ -95,7 +96,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 = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -17,6 +17,7 @@ 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;
@@ -79,7 +80,7 @@ export class InterCashierTransferComponent implements OnInit {
}
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
+2 -1
View File
@@ -14,6 +14,7 @@ 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';
@@ -357,7 +358,7 @@ export class MyWorkComponent implements OnInit {
}
private todayIso(): string {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
}
@@ -18,6 +18,7 @@ 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;
@@ -85,7 +86,7 @@ export class ReconciliationComponent implements OnInit {
loadingHistory = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
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 } from 'rxjs/operators';
import { catchError, debounceTime, distinctUntilChanged, map, timeout } 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,6 +33,7 @@ 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;
@@ -621,7 +622,7 @@ export class AccountStatementDialogComponent implements OnInit {
@ViewChild('statementContent') statementContent!: ElementRef;
today = new Date().toISOString().slice(0, 10);
today = businessTodayIso();
fromDate = '';
toDate = this.today;
@@ -1870,7 +1871,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
saving = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
this.addRow();
this.loadDenominations();
@@ -2103,7 +2104,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
private loadOpeningPosition(): void {
const d = new Date();
const d = businessToday();
const asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.cashierService
.getCashierPositionForMe(asOfDate)
@@ -2837,7 +2838,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
fundingAccountId: number,
fundingClientId: number | null
): void {
const d = new Date();
const d = businessToday();
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';
@@ -2969,7 +2970,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const d = new Date();
const d = businessToday();
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';
@@ -3068,7 +3069,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const d = new Date();
const d = businessToday();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const payload = {
@@ -3391,7 +3392,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = new Date();
const today = businessToday();
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
@@ -3425,6 +3426,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
meetingDate: shareRow.meetingDate,
description: shareRow.description || null,
settlementPaymentTypeId: savingsRow.paymentTypeId,
settlementMode: 'SAVINGS',
settlementRowSnapshot
};
@@ -3455,11 +3457,60 @@ 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.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
this.showShareWithdrawalConfirm(payload, confirmLines, () =>
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
@@ -3512,7 +3563,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = new Date();
const today = businessToday();
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
@@ -3571,7 +3622,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.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
this.showShareWithdrawalConfirm(payload, confirmLines, () =>
this.doSubmitShareGlSettlementTransfer(payload, shareRow, shares, glRow.acNo)
);
}
@@ -4173,7 +4224,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
): void {
const accountUrl = `${cfg.detail}/${accountId}${cfg.detailParams ?? '?associations=all'}`;
const todayStr = (() => {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
})();
@@ -4549,7 +4600,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
get todayIso(): string {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -5071,7 +5122,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.sameDayChequeClearing = 0;
return;
}
const today = new Date();
const today = businessToday();
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);
@@ -5195,7 +5246,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.showAlert('error', 'Invalid Account Type', [
`Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`,
'Only Savings (SD) and Current (CA) accounts are permitted.',
'GL, FD, RD, SHR accounts are not allowed — use Disbursement/Collection instead.'
'GL, FD and SHR accounts are not allowed — use Disbursement/Collection instead.',
'RD installments are paid through a cashier Receipt or Auto Debit.'
]);
return;
}
@@ -5249,6 +5301,19 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.rows.length >= 2 &&
this.rows.every((r) => r.typ === 'Disbursement' || r.typ === 'Fund Transfer')
) {
// A Recurring Deposit is never paid this way: these requests post straight into the account, so the
// installment schedule never hears of the money -- installments stay unpaid, Auto Debit collects them
// again and the maturity amount comes out wrong. RD installments come in through the cashier's
// Receipt or the RD's own Auto Debit.
const rdRows = this.rows.filter((r) => r.acTyp === 'RD');
if (rdRows.length > 0) {
this.showAlert('error', 'Recurring Deposit Not Allowed', [
`RD account(s) ${rdRows.map((r) => r.acNo).join(', ')} cannot be used in a Disbursement, Collection or Fund Transfer.`,
'Pay RD installments through a cashier Receipt, or by Auto Debit from the linked savings account.'
]);
return;
}
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
@@ -5959,7 +6024,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = new Date();
const today = businessToday();
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
const payload: any = {
@@ -6154,7 +6219,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 = new Date();
const today = businessToday();
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);
@@ -6259,7 +6324,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 = new Date();
const today = businessToday();
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
@@ -6370,7 +6435,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = new Date();
const today = businessToday();
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,6 +16,7 @@ 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;
@@ -67,7 +68,7 @@ export class VaultPositionComponent implements OnInit {
errorMsg = '';
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
@@ -13,6 +13,7 @@ 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.
@@ -227,7 +228,7 @@ export class VoucherReportComponent implements OnInit {
}
private todayIso(): string {
const now = new Date();
const now = businessToday();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
@@ -129,6 +129,31 @@
<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) {
@@ -386,14 +411,25 @@
<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 } from '@angular/forms';
import { FormsModule, UntypedFormControl } 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,6 +19,9 @@ 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';
@@ -51,7 +54,8 @@ interface ActivityEntry {
RouterModule,
MatIconModule,
MatSnackBarModule,
SearchSelectComponent
SearchSelectComponent,
GlAccountSelectorComponent
]
})
export class ShareWithdrawalRequestComponent implements OnInit {
@@ -60,6 +64,7 @@ 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
@@ -91,6 +96,17 @@ 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;
@@ -132,6 +148,17 @@ 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;
@@ -141,6 +168,7 @@ 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;
}
@@ -160,11 +188,18 @@ export class ShareWithdrawalRequestComponent implements OnInit {
pass: shares > 0 && shares <= held,
detail: `${shares} of ${held} shares held`
},
{
label: 'Linked savings account confirmed',
pass: !!this.account?.savingsAccountNumber,
detail: this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
},
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: 'Settlement Payment Type selected (non-cash)',
pass: this.settlementPaymentTypeName !== '—',
@@ -214,11 +249,37 @@ 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) {
@@ -246,6 +307,11 @@ 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 {
@@ -289,7 +355,9 @@ export class ShareWithdrawalRequestComponent implements OnInit {
resolutionNumber: this.resolutionNumber.trim(),
meetingDate: this.meetingDate,
description: this.description,
settlementPaymentTypeId: this.settlementPaymentTypeId
settlementPaymentTypeId: this.settlementPaymentTypeId,
settlementMode: this.settlementMode,
...(this.settlementMode === 'GL' ? { settlementGlAccountId: this.settlementGlAccountId } : {})
};
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 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.
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.
</div>
</div>
}
@@ -192,20 +192,90 @@
}
</span>
</div>
@if (declarationOnRecord && declarationFy) {
@if (declarationRecord; as d) {
<div class="f121__kv-row">
<span>Declared for</span>
<span>FY {{ declarationFy }}</span>
<span>FY {{ d.fy }} · Form {{ d.formType }}</span>
</div>
}
@if (declarationOnRecord && !acceptanceDateKnown) {
<div class="f121__kv-row f121__kv-row--muted">
<span>Acceptance date</span>
<div class="f121__kv-row">
<span>Accepted on</span>
<span>
Not captured — the declaration is treated as covering the whole year. A mid-year acceptance would change
the figures below.
{{ 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 (eligibility) {
@for (reason of eligibility.reasons; track reason) {
@@ -332,3 +332,61 @@
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,6 +23,8 @@ 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 {
@@ -111,6 +113,7 @@ 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>();
@@ -124,23 +127,25 @@ export class Form121TabComponent implements OnInit, OnDestroy {
pan = '';
panWellFormed = false;
// ── Declaration on record ──
declarationOnRecord = 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[] = [];
/**
* 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';
/** 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;
// ── Recording a declaration ──
showDeclarationForm = false;
savingDeclaration = false;
declarationError = '';
newDeclaration = { acceptedOn: '', uin: '', estimatedIncome: null as number | null };
eligibility: EligibilityResult | null = null;
@@ -157,9 +162,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 })[] = [];
/** Withheld-tax postings by FY, from the ledger. */
private actualTdsByFy = new Map<string, number>();
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>();
ngOnInit(): void {
this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => {
@@ -182,13 +187,15 @@ 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)))
accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null))),
declarations: this.tdsService.getDeclarations(Number(this.clientId)).pipe(catchError(() => of([])))
})
.pipe(
takeUntil(this.destroy$),
switchMap(({ client, kyc, accounts }) => {
switchMap(({ client, kyc, accounts, declarations }) => {
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[]);
}),
@@ -212,12 +219,8 @@ 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. */
@@ -251,7 +254,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
*/
private ingest(details: any[]): void {
this.accountInputs = [];
this.actualTdsByFy = new Map<string, number>();
this.platformByFy = new Map<string, TdsDeductee | null>();
const fys = new Set<string>();
const clientIdNum = Number(this.clientId);
@@ -272,6 +275,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
};
const credits: InterestCredit[] = [];
const taxLines: InterestCredit[] = [];
let tdsDeducted = 0;
for (const t of d.transactions ?? []) {
@@ -289,8 +293,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
} else if (t.transactionType?.withholdTax) {
const amount = t.amount ?? 0;
tdsDeducted += amount;
const txnFy = fyOf(iso);
this.actualTdsByFy.set(txnFy, (this.actualTdsByFy.get(txnFy) ?? 0) + amount);
taxLines.push({ date: iso, amount, accountNo: ref.accountNo });
}
}
@@ -313,12 +316,12 @@ export class Form121TabComponent implements OnInit, OnDestroy {
deducteeClientId: deductee
};
this.accountInputs.push({ ...ref, credits, meta });
this.accountInputs.push({ ...ref, credits, taxLines, meta });
}
this.availableFys = [...fys].sort().reverse();
if (!this.availableFys.includes(this.fy)) {
this.fy = this.availableFys[0] ?? (this.declarationFy ? normaliseFy(this.declarationFy) : '');
this.fy = this.availableFys[0] ?? this.declarations[0]?.fy ?? Object.keys(FY_TAX_CONFIG).sort().reverse()[0] ?? '';
}
}
@@ -338,11 +341,35 @@ 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;
}
this.actualTds = this.actualTdsByFy.get(year) ?? 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);
if (!this.dateOfBirth || this.configMissing) {
return;
@@ -382,21 +409,95 @@ export class Form121TabComponent implements OnInit, OnDestroy {
return applicable.some((a) => a.meta.mode === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
}
/** The declaration, but only when it was filed for the year being shown. */
private declarationForSelectedFy(): Declaration | null {
if (!this.declarationOnRecord || !this.fy) {
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) {
return null;
}
const year = normaliseFy(this.fy);
if (this.declarationFy && normaliseFy(this.declarationFy) !== year) {
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) {
return null;
}
return {
kind: this.declarationKind,
// No acceptance date on record — treated as covering the whole year.
acceptedOn: `${year.slice(0, 4)}-04-01`,
fy: year
};
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.')
});
}
// ───────────────────────── Template helpers ─────────────────────────
@@ -474,11 +575,12 @@ export class Form121TabComponent implements OnInit, OnDestroy {
}
/**
* A declaration is on record but deposit accounts are still set to withhold —
* the branch has not switched them off.
* 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.
*/
get declarationNotAppliedToAccounts(): boolean {
return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => a.withHoldTax);
return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => !!a.taxGroup);
}
/**
@@ -591,16 +591,13 @@
<i class="fa fa-calendar section-header__icon"></i>
<h3 class="section-header__title">{{ 'labels.heading.Recurring Deposit Accounts' | translate }}</h3>
</div>
<div class="section-header__right">
<button mat-raised-button color="primary" (click)="toggleRecurringAccountsOverview()">
{{ viewAccountsLabel(showClosedRecurringAccounts) | translate }}
</button>
</div>
</div>
@if (!showClosedRecurringAccounts) {
<!-- Active and ended recurring deposits share one table; the Status column says how each one ended (matured or
closed early), so the client's RD history stays visible without switching views. -->
@if (savingAccounts) {
<div class="table-wrapper">
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'open' : 'isRecurring'">
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'all' : 'isRecurring'">
<ng-container matColumnDef="Account No">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
<td mat-cell *matCellDef="let element">
@@ -614,6 +611,15 @@
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
</td>
</ng-container>
<ng-container matColumnDef="Status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let element">
<span class="rd-status" [class.rd-status--ended]="element.timeline?.closedOnDate">{{ rdStatusLabel(element) }}</span>
@if (element.timeline?.closedOnDate) {
<div class="rd-status__date">on {{ element.timeline.closedOnDate | dateFormat }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="Last Active">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
@@ -638,7 +644,7 @@
<i class="fa fa-check"></i>
</button>
}
@if (!element.status.submittedAndPendingApproval && !element.status.active) {
@if (element.status.approved) {
<button
class="account-action-button"
mat-raised-button
@@ -664,7 +670,7 @@
</ng-container>
<ng-container matColumnDef="no-data">
<td mat-footer-cell *matFooterCellDef [attr.colspan]="openSavingsColumns.length">
<td mat-footer-cell *matFooterCellDef [attr.colspan]="recurringColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">
@@ -674,67 +680,17 @@
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="openSavingsColumns"></tr>
<tr mat-header-row *matHeaderRowDef="recurringColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: openSavingsColumns"
*matRowDef="let row; columns: recurringColumns"
[routerLink]="['../', 'recurring-deposits-accounts', row.id, 'general']"
class="select-row"
></tr>
<tr
mat-footer-row
*matFooterRowDef="
(savingAccounts | accountsFilter: 'saving' : 'open' : 'isRecurring')?.length === 0 ? ['no-data'] : []
"
></tr>
</table>
</div>
}
<!-- Closed Recurring Deposit Accounts -->
@if (showClosedRecurringAccounts) {
<div class="table-wrapper">
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'closed' : 'isRecurring'">
<ng-container matColumnDef="Account No">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
<td mat-cell *matCellDef="let element">
<i class="fa fa-stop" [ngClass]="element.status.code | statusLookup"></i>
<mifosx-account-number accountNo="{{ element.accountNo }}"></mifosx-account-number>
</td>
</ng-container>
<ng-container matColumnDef="Saving Account">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Recurring Deposit Product' | translate }}</th>
<td mat-cell *matCellDef="let element">
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
</td>
</ng-container>
<ng-container matColumnDef="Closed Date">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Closed Date' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.timeline.closedOnDate | dateFormat }}</td>
</ng-container>
<ng-container matColumnDef="no-data">
<td mat-footer-cell *matFooterCellDef [attr.colspan]="closedSavingsColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">
{{ 'labels.messages.No Closed Recurring Deposit Accounts Available' | translate }}
</p>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="closedSavingsColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: closedSavingsColumns"
[routerLink]="['../', 'recurring-deposits-accounts', row.id, 'general']"
class="select-row"
></tr>
<tr
mat-footer-row
*matFooterRowDef="
(savingAccounts | accountsFilter: 'saving' : 'closed' : 'isRecurring')?.length === 0 ? ['no-data'] : []
(savingAccounts | accountsFilter: 'saving' : 'all' : 'isRecurring')?.length === 0 ? ['no-data'] : []
"
></tr>
</table>
@@ -419,3 +419,24 @@ table[mat-table] {
font-size: 24px;
z-index: 1;
}
.rd-status {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
background: #dcfce7;
color: #166534;
&--ended {
background: #e2e8f0;
color: #475569;
}
}
.rd-status__date {
margin-top: 2px;
font-size: 11px;
color: #64748b;
}
@@ -187,6 +187,15 @@ export class GeneralTabComponent implements OnDestroy {
'Saving Account',
'Closed Date'
];
/** Closed Recurring Deposit Accounts Columns - with how each one ended, for tracking. */
recurringColumns: string[] = [
'Account No',
'Saving Account',
'Status',
'Last Active',
'Balance',
'Actions'
];
/** Open Shares Accounts Columns */
openSharesColumns: string[] = [
'Account No',
@@ -475,6 +484,30 @@ export class GeneralTabComponent implements OnDestroy {
);
}
/** Where a recurring deposit stands - for one that has ended, how it ended. */
rdStatusLabel(account: any): string {
switch (account?.status?.code) {
case 'savingsAccountStatusType.active':
return 'Active';
case 'savingsAccountStatusType.matured':
return 'Matured (not closed)';
case 'savingsAccountStatusType.submitted.and.pending.approval':
return 'Pending Approval';
case 'savingsAccountStatusType.approved':
return 'Approved';
case 'savingsAccountStatusType.closed':
return 'Matured & Closed';
case 'savingsAccountStatusType.pre.mature.closure':
return 'Premature Closed';
case 'savingsAccountStatusType.rejected':
return 'Rejected';
case 'savingsAccountStatusType.withdrawn.by.applicant':
return 'Withdrawn by Client';
default:
return account?.status?.value ?? '';
}
}
viewAccountsLabel(closed: boolean): string {
if (closed) {
return 'labels.buttons.View Active Accounts';
@@ -377,6 +377,8 @@
</button>
}
@if (selectedTabIndex < totalTabs - 1) {
<!-- Tooltip sits on a wrapper: a disabled button fires no mouse events, so it can't host it. -->
<span [matTooltip]="currentStepBlockerMessage" [matTooltipDisabled]="!currentStepBlockerMessage">
<button mat-raised-button color="primary" (click)="onNext()" [disabled]="savingClient || !currentStepValid">
@if (savingClient) {
<mat-progress-spinner diameter="18" mode="indeterminate" class="m-r-10"></mat-progress-spinner>
@@ -390,6 +392,7 @@
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
}
</button>
</span>
}
@if (selectedTabIndex === totalTabs - 1) {
<button mat-raised-button color="primary" [routerLink]="['../']">
@@ -393,6 +393,12 @@ export class EditClientComponent {
if (this.selectedTabIndex === 0 && this.clientGeneralForm?.valid && !this.contactVerificationComplete) {
return 'Verify the OTP sent to both the old and new mobile number/email before saving.';
}
if (this.selectedTabIndex === 0 && this.clientGeneralForm && !this.clientGeneralForm.valid) {
const labels = this.invalidGeneralFieldLabels();
if (labels.length) {
return `Please fill/correct: ${labels.join(', ')}.`;
}
}
if (this.selectedTabIndex === 1) {
const labels = this.clientKycStep?.invalidFieldLabels || [];
if (labels.length) {
@@ -408,6 +414,42 @@ export class EditClientComponent {
return 'Please fill all mandatory fields on this tab to continue.';
}
/**
* Readable names of the General-tab controls that are invalid. Older clients can be missing
* fields that are now mandatory (e.g. mobile/email), which leaves Save disabled with nothing
* on screen saying why — this feeds the button tooltip.
*/
private invalidGeneralFieldLabels(): string[] {
const labels: Record<string, string> = {
officeId: 'Office',
legalFormId: 'Legal Form',
genderId: 'Gender',
mobileNo: 'Mobile No',
alternateMobileNo: 'Alternate Mobile No',
emailAddress: 'Email',
dateOfBirth: 'Date of Birth',
submittedOnDate: 'Submitted On',
activationDate: 'Activation Date',
savingsProductId: 'Savings Product',
firstname: 'First Name',
middlename: 'Middle Name',
lastname: 'Last Name',
fullname: 'Name',
monthlyIncome: 'Monthly Income',
annualIncome: 'Annual Income',
monthlyTurnover: 'Monthly Turnover',
annualTurnover: 'Yearly Turnover',
agricultureIncome: 'Agriculture Income',
nonAgricultureIncome: 'Non-Agriculture Income',
guardianDetails: 'Guardian',
clientNonPersonDetails: 'Entity Details'
};
const controls = this.clientGeneralForm?.controls || {};
return Object.keys(controls)
.filter((name) => controls[name].invalid)
.map((name) => labels[name] || name);
}
/** Whether the current tab has unsaved edits */
isCurrentTabEdited(): boolean {
return this.tabEditMode[this.selectedTabIndex] === true;
+18 -1
View File
@@ -36,7 +36,24 @@ export class CacheInterceptor implements HttpInterceptor {
'/rbireserveconfig',
'/tdsconfig',
'/tds/deposit-interest',
'/savingsinterestpostingreports'
// 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.
'/tellers',
'/cashreports',
'/cashreconciliation',
'/cashsettlement',
'/cashtransfer',
'/branchday',
// A deposit's lien moves with every repayment of the loan it secures, and decides whether the
// deposit may be closed at all - a stale copy would show a released lien as still blocking.
'/liens',
// The RD Dashboard counts late installments as of today; a cached copy would hide a payment just taken.
'/recurringdepositaccounts/dashboard'
];
private isCacheable(url: string): boolean {
+27 -5
View File
@@ -67,12 +67,18 @@ 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;
isKycRiskRulesRequest ||
isDocumentAttachmentRequest;
// 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
@@ -93,8 +99,8 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
throw response;
}
// 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.
// 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.
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!' };
@@ -119,7 +125,13 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
message: errorMessage || 'This action is not allowed in the current state.'
};
} else if (status === 500) {
alert = { type: 'Internal Server Error', message: 'Internal Server Error. Please try again later.' };
// 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.'
};
} else if (status === 501) {
alert = {
type: this.translate.instant('error.resource.notImplemented.type'),
@@ -133,7 +145,17 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
}
if (alert) {
this.alertService.alert({ ...alert, severity: 'error' });
// 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' });
}
throw response;
+372 -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']">
@@ -1220,6 +1262,9 @@
<button mat-menu-item tabindex="0" [routerLink]="['/fd-dashboard']">
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> FD Dashboard
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/rd-dashboard']">
<fa-icon icon="piggy-bank" class="menu-item-icon"></fa-icon> RD Dashboard
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> FD Interest Report
</button>
@@ -1236,34 +1281,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 +1635,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 +1679,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 +1691,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 +1708,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 +1729,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 +1742,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 +1922,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,9 @@
<div class="t-live"></div>
&nbsp;LIVE
</div>
<div class="t-chip">Branch:&nbsp;<strong>HO</strong></div>
<div class="t-chip">
Branch:&nbsp;<strong>{{ officeLabel }}</strong>
</div>
<div class="t-chip">
Date:&nbsp;<strong>{{ today | date: 'dd MMM yyyy' }}</strong>
</div>
@@ -522,7 +524,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,6 +49,7 @@ 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.
@@ -92,6 +93,14 @@ 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)
@@ -100,8 +109,10 @@ export class ToolbarComponent implements OnInit, AfterViewInit, AfterContentChec
/** Sets the initial state of sidenav as collapsed. Not collapsed if false. */
sidenavCollapsed = true;
/** Current date for top bar display */
today = new Date();
/** The bank's "today" for the top bar — the business date when enabled, else the server date. */
get today(): Date {
return businessToday();
}
/** Last login time display */
lastLoginTime = this.formatLastLogin();
+30
View File
@@ -0,0 +1,30 @@
/**
* 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')}`;
}
@@ -195,6 +195,13 @@ export class CreateRecurringDepositsAccountComponent {
});
return;
}
if (this.recurringDepositsAccountProductTemplate?.introducerRequired && !detailsStep?.introducerId) {
this.alertService.alert({
type: 'Error',
message: 'This product requires an introducer. Please choose an existing customer as introducer before submitting.'
});
return;
}
if (!this.selectedTenureValue || this.selectedTenureFrequencyId === null) {
this.alertService.alert({
type: 'Error',
@@ -209,6 +216,39 @@ export class CreateRecurringDepositsAccountComponent {
});
return;
}
// Same limits the server enforces - checked here so the message names the numbers before anything is sent.
const minInstallment = Number(this.recurringDepositsAccountProductTemplate?.minDepositAmount) || 0;
const maxInstallment = Number(this.recurringDepositsAccountProductTemplate?.maxDepositAmount) || 0;
if (minInstallment > 0 && this.selectedDepositAmount < minInstallment) {
this.alertService.alert({
type: 'Error',
message: `Installment ${this.selectedDepositAmount} is below this product's minimum of ${minInstallment}.`
});
return;
}
if (maxInstallment > 0 && this.selectedDepositAmount > maxInstallment) {
this.alertService.alert({
type: 'Error',
message: `Installment ${this.selectedDepositAmount} is above this product's maximum of ${maxInstallment}.`
});
return;
}
if (
this.recurringDepositsAccountCurrencyStep?.autoDebitFacility &&
detailsStep?.selectedLinkedAccount?.accountType !== 'SAVINGS'
) {
this.alertService.alert({
type: 'Error',
message:
'Auto Debit collects each installment from the linked account. Link a Savings account under Deposit Account Link, or turn Auto Debit off.'
});
return;
}
const lienError = this.recurringDepositsAccountCurrencyStep?.lienPercentageError;
if (lienError) {
this.alertService.alert({ type: 'Error', message: `Loan against this RD: ${lienError}` });
return;
}
if (!detailsStep?.selectedCustomerSegment) {
this.alertService.alert({ type: 'Error', message: 'Please select a Customer Segment before submitting.' });
return;
@@ -255,8 +295,16 @@ export class CreateRecurringDepositsAccountComponent {
isCalendarInherited: pt?.isCalendarInherited ?? false,
isMandatoryDeposit: pt?.isMandatoryDeposit ?? false,
allowWithdrawal: pt?.allowWithdrawal ?? false,
adjustAdvanceTowardsFuturePayments: pt?.adjustAdvanceTowardsFuturePayments ?? false,
autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false
// Money paid ahead of a due date counts towards the next installments. Without this an advance cash
// payment stayed unallocated: the schedule kept showing the installment unpaid, so Auto Debit collected it
// a second time and the missed-installment penalty charged it. The RD product form has no such option, so
// the product's value was always false.
adjustAdvanceTowardsFuturePayments: true,
autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false,
// Lien Marked products only - the server ignores it otherwise and caps it at the product's limit.
...(currencyStep?.lienAllowed && currencyStep?.lienPercentage != null
? { lienPercentage: Number(currencyStep.lienPercentage) }
: {})
};
// Charges from product template
@@ -233,6 +233,11 @@ export class EditRecurringDepositAccountComponent {
});
return;
}
const lienError = this.recurringDepositsAccountCurrencyStep?.lienPercentageError;
if (lienError) {
this.alertService.alert({ type: 'Error', message: `Loan against this RD: ${lienError}` });
return;
}
if (!detailsStep?.selectedCustomerSegment) {
this.alertService.alert({ type: 'Error', message: 'Please select a Customer Segment before submitting.' });
return;
@@ -275,8 +280,13 @@ export class EditRecurringDepositAccountComponent {
isCalendarInherited: pt?.isCalendarInherited ?? false,
isMandatoryDeposit: pt?.isMandatoryDeposit ?? false,
allowWithdrawal: pt?.allowWithdrawal ?? false,
adjustAdvanceTowardsFuturePayments: pt?.adjustAdvanceTowardsFuturePayments ?? false,
autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false
// See create-recurring-deposits-account: advance payments must count towards the next installments.
adjustAdvanceTowardsFuturePayments: true,
autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false,
// Lien Marked products only - the server ignores it otherwise and caps it at the product's limit.
...(currencyStep?.lienAllowed && currencyStep?.lienPercentage != null
? { lienPercentage: Number(currencyStep.lienPercentage) }
: {})
};
const rawCharges: any[] = pt?.charges ?? [];
@@ -70,8 +70,9 @@ export class ActivateRecurringDepositsAccountComponent implements OnInit {
*/
createActivateRecurringDepositsAccountForm() {
this.activateRecurringDepositsAccountForm = this.formBuilder.group({
// Defaults to the business date (the branch's working day), which is what the account is normally activated on.
activatedOnDate: [
'',
this.settingsService.businessDate,
Validators.required
]
});
@@ -72,8 +72,9 @@ export class ApproveRecurringDepositsAccountComponent implements OnInit {
*/
createApproveRecurringDepositsAccountForm() {
this.approveRecurringDepositsAccountForm = this.formBuilder.group({
// Defaults to the business date (the branch's working day), which is what the account is normally approved on.
approvedOnDate: [
'',
this.settingsService.businessDate,
Validators.required
],
note: ['']
@@ -13,6 +13,16 @@
<div class="layout-column">
<h3 class="mat-h3 flex-fill">{{ title }}</h3>
@if (totalLienAmount > 0) {
<p class="lien-warn">
This deposit is pledged as loan security (lien {{ totalLienAmount | number: '1.2-2' }} -
@for (lien of liens; track lien.loanId; let last = $last) {
loan {{ lien.loanAccountNo }}{{ last ? '' : ', ' }}
}
). It cannot be closed until the loan is repaid or the guarantee is released.
</p>
}
<mat-form-field (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On' | translate }}</mat-label>
<input
@@ -154,7 +164,7 @@
<button type="button" mat-raised-button [routerLink]="['../general']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="!closeRecurringDepositForm.valid">
<button mat-raised-button color="primary" [disabled]="!closeRecurringDepositForm.valid || totalLienAmount > 0">
{{ 'labels.buttons.Submit' | translate }}
</button>
</mat-card-actions>
@@ -10,6 +10,12 @@
max-width: 37rem;
}
.lien-warn {
margin: 0 0 16px;
font-size: 13px;
color: #c62828;
}
.expandcollapsebutton {
margin-top: -7px;
margin-left: 2%;
@@ -64,6 +64,9 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
maxDate = new Date();
/** Close Recurring Deposit Account form. */
closeRecurringDepositForm: UntypedFormGroup;
/** Loans this deposit secures. While any remain, the server refuses to close it. */
liens: any[] = [];
totalLienAmount = 0;
/**
* Retrieves action details template data from `resolve`
@@ -79,6 +82,11 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
this.maturityAmount = data.recurringDepositsAccountActionData.maturityAmount;
this.onAccountClosureOptions = data.recurringDepositsAccountActionData.onAccountClosureOptions;
this.paymentTypes = data.recurringDepositsAccountActionData.paymentTypeOptions;
// A matured deposit closes on or after its maturity date - the server refuses anything earlier.
const maturityDate = data.recurringDepositsAccountActionData.maturityDate;
if (Array.isArray(maturityDate) && maturityDate.length >= 3) {
this.minDate = new Date(maturityDate[0], maturityDate[1] - 1, maturityDate[2]);
}
if (data.recurringDepositsAccountActionData.maturityAmount) {
this.title = 'Recurring Deposit Closure on Maturity';
} else {
@@ -92,6 +100,10 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
this.maxDate = this.settingsService.businessDate;
this.createcloseRecurringDepositForm();
this.addTransferDetails();
this.recurringDepositsService.getRecurringDepositLiens(this.accountId).subscribe((response: any) => {
this.liens = response?.liens ?? [];
this.totalLienAmount = Number(response?.totalLienAmount) || 0;
});
}
/**
@@ -99,8 +111,10 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
*/
createcloseRecurringDepositForm() {
this.closeRecurringDepositForm = this.formBuilder.group({
// The business date, not the computer's clock: the branch's working day can differ from the calendar day,
// and closing on the clock's date (before maturity, or before the deposit's last installment) fails.
closedOnDate: [
new Date(),
this.settingsService.businessDate,
Validators.required
],
maturityAmount: [{ value: this.maturityAmount, disabled: true }],
@@ -11,6 +11,51 @@
<form [formGroup]="prematureCloseRecurringDepositsAccountForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="layout-column">
@if (account) {
<div class="pc-context">
<div class="pc-row">
<span>Account</span><b>{{ account.accountNo }}</b>
</div>
<div class="pc-row">
<span>Current balance</span><b>{{ currencySymbol }} {{ balance | number: '1.2-2' }}</b>
</div>
@if (account.maturityDate) {
<div class="pc-row">
<span>Maturity date</span><b>{{ account.maturityDate | dateFormat }}</b>
</div>
}
@if (account.maturityAmount != null) {
<div class="pc-row">
<span>Amount at maturity</span><b>{{ currencySymbol }} {{ account.maturityAmount | number: '1.2-2' }}</b>
</div>
}
<div class="pc-row">
<span>Pre-closure penalty</span>
<b>
@if (account.preClosurePenalApplicable) {
{{ account.preClosurePenalInterest }}% less interest
@if (account.preClosurePenalInterestOnType?.value) {
({{ account.preClosurePenalInterestOnType.value }})
}
} @else {
None
}
</b>
</div>
</div>
}
@if (totalLienAmount > 0) {
<p class="pc-warn">
This deposit is pledged as loan security (lien {{ currencySymbol }}
{{ totalLienAmount | number: '1.2-2' }} -
@for (lien of liens; track lien.loanId; let last = $last) {
loan {{ lien.loanAccountNo }}{{ last ? '' : ', ' }}
}
). It cannot be closed until the loan is repaid or the guarantee is released.
</p>
}
<mat-form-field (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On' | translate }}</mat-label>
<input
@@ -23,6 +68,7 @@
/>
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closedOnDatePicker></mat-datepicker>
<mat-hint>Must be before the maturity date - on or after it, use Close instead.</mat-hint>
@if (prematureCloseRecurringDepositsAccountForm.controls.closedOnDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }}
@@ -30,6 +76,89 @@
</mat-error>
}
</mat-form-field>
@if (reasonOptions.length > 1) {
<mat-form-field subscriptSizing="dynamic">
<mat-label>Closure reason</mat-label>
<mat-select formControlName="prematureClosureReasonId">
@for (reason of reasonOptions; track reason.id) {
<mat-option [value]="reason.id">{{ reason.label }}</mat-option>
}
</mat-select>
<mat-hint>{{ selectedReasonHint }}</mat-hint>
</mat-form-field>
}
<div class="pc-calc">
<button
type="button"
mat-stroked-button
color="primary"
(click)="calculate()"
[disabled]="
calculating || totalLienAmount > 0 || !prematureCloseRecurringDepositsAccountForm.value.closedOnDate
"
>
{{ calculating ? 'Calculating…' : 'Calculate Payout' }}
</button>
</div>
@if (preview) {
<div class="pc-result">
<div class="pc-row pc-row--total">
<span>Payable on premature closure</span>
<b>{{ currencySymbol }} {{ preview.payoutAmount | number: '1.2-2' }}</b>
</div>
@if (shortfallVsMaturity != null && shortfallVsMaturity > 0) {
<div class="pc-row pc-row--muted">
<span>Less than at maturity by</span>
<b>{{ currencySymbol }} {{ shortfallVsMaturity | number: '1.2-2' }}</b>
</div>
}
<p class="pc-note">
Paid by transfer to the client's savings account - a recurring deposit cannot be broken for cash.
</p>
</div>
@if (destinationAccounts.length === 0) {
<p class="pc-warn">
This client has no active savings account to receive the proceeds. Open one first, then close this
deposit.
</p>
} @else {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Transfer to Savings' | translate }}</mat-label>
<mat-select required formControlName="toSavingsAccountId">
@for (savings of destinationAccounts; track savings.id) {
<mat-option [value]="savings.id">
{{ savings.accountNo }} - {{ savings.productName }}
@if (savings.owner) {
(nominee: {{ savings.owner }})
} @else if (savings.id === account?.linkedAccountId) {
(linked)
}
</mat-option>
}
</mat-select>
@if (prematureCloseRecurringDepositsAccountForm.controls.toSavingsAccountId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Transfer Description' | translate }}</mat-label>
<input matInput formControlName="transferDescription" />
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput cdkTextareaAutosize formControlName="note"></textarea>
</mat-form-field>
}
}
</div>
</mat-card-content>
@@ -37,8 +166,8 @@
<button type="button" mat-raised-button [routerLink]="['../../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="!prematureCloseRecurringDepositsAccountForm">
{{ 'labels.buttons.Confirm' | translate }}
<button mat-raised-button color="primary" [disabled]="!canConfirm">
{{ submitting ? 'Closing…' : ('labels.buttons.Confirm' | translate) }}
</button>
</mat-card-actions>
</form>
@@ -9,3 +9,45 @@
.container {
max-width: 37rem;
}
.pc-context,
.pc-result {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 14px;
margin-bottom: 16px;
border: 1px solid rgb(128 128 128 / 30%);
border-radius: 6px;
}
.pc-row {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 13px;
&--total {
font-size: 15px;
}
&--muted {
opacity: 0.75;
}
}
.pc-calc {
margin: 4px 0 16px;
}
.pc-note {
margin: 4px 0 0;
font-size: 12px;
opacity: 0.75;
}
.pc-warn {
margin: 0 0 16px;
font-size: 13px;
color: #c62828;
}
@@ -8,25 +8,41 @@
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
/** Custom Services */
import { RecurringDepositsService } from '../../recurring-deposits.service';
import { ClientsService } from 'app/clients/clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** DepositAccountOnClosureType.TRANSFER_TO_SAVINGS - the only way a broken RD may be paid out. */
const TRANSFER_TO_SAVINGS = 200;
/** PrematureClosureReasonType values the RD closure understands. */
const REASON_NORMAL = 100;
const REASON_DECEASED = 400;
const REASON_NOMINEE_HEIR = 500;
/**
* Premature Close Recurring Deposits Account Component
*
* The server will only break a recurring deposit early by transferring the proceeds to one of the client's savings
* accounts (no cash payout, no reinvest), and it needs to be told so: onAccountClosureId and toSavingsAccountId are
* mandatory. This screen used to send only the closure date, so every premature closure was rejected. It now asks the
* server what the deposit would pay on the chosen date (calculatePrematureAmount, which applies the product's
* pre-closure penalty) and only lets the closure be confirmed against that exact figure.
*/
@Component({
selector: 'mifosx-premature-close-recurring-deposit-account',
templateUrl: './premature-close-recurring-deposit-account.component.html',
styleUrls: ['./premature-close-recurring-deposit-account.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS
...STANDALONE_SHARED_IMPORTS,
CdkTextareaAutosize
]
})
export class PrematureCloseRecurringDepositAccountComponent implements OnInit {
@@ -36,72 +52,263 @@ export class PrematureCloseRecurringDepositAccountComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
private clientsService = inject(ClientsService);
/** Minimum date allowed. */
/** Savings accounts of this deposit's nominees - offered as destinations only for a deceased holder. */
nomineeSavingsAccounts: any[] = [];
/** Minimum date allowed: the deposit's activation date once loaded. */
minDate = new Date(2000, 0, 1);
/** Maximum date allowed. */
/** Maximum date allowed: the business date, and never the maturity date or later (that is a normal Close). */
maxDate = new Date();
/** premature close Recurring Deposits Account form. */
prematureCloseRecurringDepositsAccountForm: UntypedFormGroup;
/** Recurring Deposits Account Id */
accountId: any;
/** The deposit being closed, for the context panel and the default destination account. */
account: any;
/** Result of the last calculatePrematureAmount call, and the date it was calculated for. */
preview: { payoutAmount: number; savingsAccounts: any[]; closureOptionId: number } | null = null;
previewedFor: string | null = null;
calculating = false;
submitting = false;
/** Loans this deposit secures. While any remain, the server refuses to close it. */
liens: any[] = [];
totalLienAmount = 0;
/**
* @param {FormBuilder} formBuilder Form Builder
* @param {RecurringDepositsService} recurringDepositsService Recurring Deposits Service
* @param {Dates} dateUtils Date Utils
* @param {ActivatedRoute} route Activated Route
* @param {Router} router Router
* @param {SettingsService} settingsService Settings Service
*/
constructor() {
this.accountId = this.route.parent.snapshot.params['recurringDepositAccountId'];
}
/**
* Creates the premature close recurring deposits form.
*/
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.createprematureCloseRecurringDepositsAccountForm();
}
/**
* Creates the premature close recurring deposits account form.
*/
createprematureCloseRecurringDepositsAccountForm() {
this.prematureCloseRecurringDepositsAccountForm = this.formBuilder.group({
closedOnDate: [
'',
Validators.required
]
this.recurringDepositsService.getRecurringDepositsAccountData(this.accountId).subscribe((account: any) => {
this.account = account;
const activatedOn = this.toDate(account?.timeline?.activatedOnDate);
if (activatedOn) {
this.minDate = activatedOn;
}
const maturity = this.toDate(account?.maturityDate);
if (maturity) {
const dayBeforeMaturity = new Date(maturity);
dayBeforeMaturity.setDate(dayBeforeMaturity.getDate() - 1);
if (dayBeforeMaturity < this.maxDate) {
this.maxDate = dayBeforeMaturity;
}
}
});
this.recurringDepositsService.getRecurringDepositLiens(this.accountId).subscribe((response: any) => {
this.liens = response?.liens ?? [];
this.totalLienAmount = Number(response?.totalLienAmount) || 0;
});
// A different date or reason is a different payout - the old preview no longer describes what would happen.
this.prematureCloseRecurringDepositsAccountForm.get('closedOnDate').valueChanges.subscribe(() => {
this.preview = null;
this.previewedFor = null;
});
this.prematureCloseRecurringDepositsAccountForm.get('prematureClosureReasonId').valueChanges.subscribe((reason: number) => {
this.preview = null;
this.previewedFor = null;
if (reason === REASON_DECEASED) {
this.loadNomineeSavingsAccounts();
}
});
}
/**
* Submits the form and premature closes the recurring deposit account,
* if successful redirects to the recurring deposit account.
*/
submit() {
const prematureCloseRecurringDepositsAccountFormData = this.prematureCloseRecurringDepositsAccountForm.value;
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const prevClosedOnDate: Date = this.prematureCloseRecurringDepositsAccountForm.value.closedOnDate;
if (prematureCloseRecurringDepositsAccountFormData.closedOnDate instanceof Date) {
prematureCloseRecurringDepositsAccountFormData.closedOnDate = this.dateUtils.formatDate(
prevClosedOnDate,
dateFormat
);
/** Reasons this deposit's product allows; Normal is always there. */
get reasonOptions(): Array<{ id: number; label: string; hint: string }> {
const options = [{ id: REASON_NORMAL, label: 'Normal', hint: "Pre-closure penalty as per the deposit's terms." }];
if (this.account?.noPenaltyNomineeHeirClosure) {
options.push({ id: REASON_NOMINEE_HEIR, label: 'Nominee / legal heir claim', hint: 'No pre-closure penalty.' });
}
const data = {
...prematureCloseRecurringDepositsAccountFormData,
dateFormat,
locale
};
this.recurringDepositsService
.executeRecurringDepositsAccountCommand(this.accountId, 'prematureClose', data)
.subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
if (this.account?.deceasedSettlement) {
options.push({
id: REASON_DECEASED,
label: 'Account holder deceased',
hint: "No penalty, allowed inside lock-in; may be paid to a nominee's savings account."
});
}
return options;
}
get selectedReasonHint(): string {
const reason = this.prematureCloseRecurringDepositsAccountForm.value.prematureClosureReasonId;
return this.reasonOptions.find((option) => option.id === reason)?.hint ?? '';
}
/** The client's own savings accounts, plus - for a deceased holder - the nominees' savings accounts. */
get destinationAccounts(): any[] {
const own = (this.preview?.savingsAccounts ?? []).map((acc: any) => ({ ...acc, owner: null }));
if (this.prematureCloseRecurringDepositsAccountForm.value.prematureClosureReasonId !== REASON_DECEASED) {
return own;
}
const ownIds = new Set(own.map((acc: any) => acc.id));
return [
...own,
...this.nomineeSavingsAccounts.filter((acc: any) => !ownIds.has(acc.id))
];
}
private loadNomineeSavingsAccounts() {
this.nomineeSavingsAccounts = [];
const clientId = this.account?.clientId;
if (!clientId || !this.account?.accountNo) {
return;
}
this.clientsService.getClientNominees(clientId, this.account.accountNo, 'RD').subscribe((nominees: any) => {
const list: any[] = Array.isArray(nominees) ? nominees : (nominees?.pageItems ?? []);
list
.filter((nominee: any) => nominee?.nomineeClientId && nominee.isActive !== false)
.forEach((nominee: any) => {
this.clientsService.getClientAccountData(nominee.nomineeClientId).subscribe((accounts: any) => {
const savings = (accounts?.savingsAccounts ?? []).filter(
(acc: any) => acc?.depositType?.id === 100 && acc?.status?.active
);
this.nomineeSavingsAccounts = [
...this.nomineeSavingsAccounts,
...savings.map((acc: any) => ({
id: acc.id,
accountNo: acc.accountNo,
productName: acc.productName,
owner: nominee.nomineeDisplayName
}))
];
});
});
});
}
createprematureCloseRecurringDepositsAccountForm() {
this.prematureCloseRecurringDepositsAccountForm = this.formBuilder.group({
closedOnDate: [
this.settingsService.businessDate,
Validators.required
],
prematureClosureReasonId: [REASON_NORMAL],
toSavingsAccountId: [
null,
Validators.required
],
transferDescription: ['Premature closure of recurring deposit'],
note: ['']
});
}
get balance(): number | null {
return this.account?.summary?.accountBalance ?? null;
}
get currencySymbol(): string {
return this.account?.currency?.displaySymbol || this.account?.currency?.code || '';
}
/** What breaking early costs compared with letting the deposit run to maturity, when both are known. */
get shortfallVsMaturity(): number | null {
const maturityAmount = this.account?.maturityAmount;
if (!this.preview || maturityAmount == null) {
return null;
}
return Number(maturityAmount) - Number(this.preview.payoutAmount);
}
get canConfirm(): boolean {
return (
this.totalLienAmount <= 0 &&
!!this.preview &&
this.previewedFor === this.formattedClosedOnDate &&
this.prematureCloseRecurringDepositsAccountForm.valid &&
!this.submitting
);
}
private get formattedClosedOnDate(): string | null {
const value = this.prematureCloseRecurringDepositsAccountForm.value.closedOnDate;
if (!value) {
return null;
}
return value instanceof Date ? this.dateUtils.formatDate(value, this.settingsService.dateFormat) : value;
}
/** Asks the server what the deposit pays if broken on the chosen date. */
calculate() {
const closedOnDate = this.formattedClosedOnDate;
if (!closedOnDate) {
return;
}
this.calculating = true;
const data = {
closedOnDate,
prematureClosureReasonId: this.prematureCloseRecurringDepositsAccountForm.value.prematureClosureReasonId,
dateFormat: this.settingsService.dateFormat,
locale: this.settingsService.language.code
};
this.recurringDepositsService.executeRecurringDepositsAccountCommand(this.accountId, 'calculatePrematureAmount', data).subscribe({
next: (response: any) => {
const savingsAccounts = response?.savingsAccounts ?? [];
const closureOptionId =
(response?.onAccountClosureOptions ?? []).find((option: any) => option.id === TRANSFER_TO_SAVINGS)?.id ??
TRANSFER_TO_SAVINGS;
this.preview = { payoutAmount: response?.maturityAmount, savingsAccounts, closureOptionId };
this.previewedFor = closedOnDate;
this.calculating = false;
this.selectDefaultDestination(savingsAccounts);
},
error: () => {
this.calculating = false;
}
});
}
/** The deposit's own linked savings account, when it has one the client can still receive into. */
private selectDefaultDestination(savingsAccounts: any[]) {
const control = this.prematureCloseRecurringDepositsAccountForm.get('toSavingsAccountId');
if (control.value && savingsAccounts.some((acc: any) => acc.id === control.value)) {
return;
}
const linkedId = this.account?.linkedAccountId;
const linked = savingsAccounts.find((acc: any) => acc.id === linkedId);
control.setValue(linked?.id ?? (savingsAccounts.length === 1 ? savingsAccounts[0].id : null));
}
submit() {
if (!this.canConfirm) {
return;
}
const formValue = this.prematureCloseRecurringDepositsAccountForm.value;
const data: any = {
closedOnDate: this.formattedClosedOnDate,
onAccountClosureId: this.preview.closureOptionId,
prematureClosureReasonId: formValue.prematureClosureReasonId,
toSavingsAccountId: formValue.toSavingsAccountId,
transferDescription: formValue.transferDescription,
dateFormat: this.settingsService.dateFormat,
locale: this.settingsService.language.code
};
if (formValue.note) {
data.note = formValue.note;
}
this.submitting = true;
this.recurringDepositsService.executeRecurringDepositsAccountCommand(this.accountId, 'prematureClose', data).subscribe({
next: () => {
this.router.navigate(['../../'], { relativeTo: this.route });
},
error: () => {
this.submitting = false;
}
});
}
/** API dates arrive as [yyyy, m, d] arrays or ISO strings. */
private toDate(value: any): Date | null {
if (!value) {
return null;
}
if (Array.isArray(value)) {
return new Date(value[0], value[1] - 1, value[2]);
}
const parsed = new Date(value);
return isNaN(parsed.getTime()) ? null : parsed;
}
}
@@ -30,6 +30,14 @@
@if (actions['Close']) {
<mifosx-close-recurring-deposits-account></mifosx-close-recurring-deposits-account>
}
@if (actions['Deposit'] || actions['Withdrawal']) {
<!-- A direct deposit from the account screen skips the manager's approval; RD installments come in through the
cashier's Receipt or Auto Debit instead, so the Deposit action is not offered here even by URL. -->
@if (actions['Deposit']) {
<p>
RD installments cannot be deposited from the account screen. Use a cashier Receipt, or Auto Debit from the linked
savings account.
</p>
}
@if (actions['Withdrawal']) {
<mifosx-deposit-recurring-deposits-account [currency]="currency"></mifosx-deposit-recurring-deposits-account>
}
@@ -30,11 +30,50 @@
<div class="racc-alert racc-alert--editable">
<div class="racc-alert__content">
<h4>Auto Debit Facility</h4>
<p>Enable automatic deduction of funds for recurring payments, utility bills, or EMI obligations.</p>
@if (recurringDepositsAccountProductTemplate?.autoDebitFacility) {
<p>
Collect each installment automatically on its due date from the linked Savings account (Deposit Account
Link, Details tab). A short balance leaves it overdue and it is retried on the next run.
</p>
} @else {
<p>This product does not offer Auto Debit.</p>
}
</div>
<mat-slide-toggle [(ngModel)]="autoDebitFacility" color="primary"></mat-slide-toggle>
<mat-slide-toggle
[(ngModel)]="autoDebitFacility"
[disabled]="!recurringDepositsAccountProductTemplate?.autoDebitFacility"
color="primary"
></mat-slide-toggle>
</div>
@if (lienAllowed) {
<div class="racc-alert racc-alert--editable">
<div class="racc-alert__content">
<h4>Loan against this RD (Lien)</h4>
<p>
How much of the RD balance can be given as loan, checked on the balance at the time the loan is taken.
Product limit: {{ productLienLimit }}%.
</p>
@if (lienPercentageError) {
<p class="racc-lien-error">{{ lienPercentageError }}</p>
}
</div>
<mat-form-field appearance="outline" class="racc-lien-field" subscriptSizing="dynamic">
<mat-label>Max loan (%)</mat-label>
<input
matInput
type="number"
min="1"
[max]="productLienLimit"
step="0.01"
[(ngModel)]="lienPercentage"
[ngModelOptions]="{ standalone: true }"
/>
<span matTextSuffix>%</span>
</mat-form-field>
</div>
}
<div class="racc-alert">
<div class="racc-alert__content">
<h4>Dormancy Alert</h4>
@@ -126,6 +126,15 @@ $gray-900: #202124;
}
}
.racc-lien-field {
width: 140px;
flex-shrink: 0;
}
.racc-lien-error {
color: #c62828;
}
.racc-alert--editable {
border-color: #bae6fd;
background: #f0f9ff;
@@ -6,8 +6,9 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, Input, OnChanges, SimpleChanges, ViewChild } from '@angular/core';
import { Component, Input, OnChanges, SimpleChanges, ViewChild, inject } from '@angular/core';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RecurringDepositsService } from '../../recurring-deposits.service';
import { NomineeFormComponent } from 'app/shared/nominee-form/nominee-form.component';
import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component';
import { MatSlideToggle } from '@angular/material/slide-toggle';
@@ -36,7 +37,14 @@ export class RecurringDepositsAccountCurrencyStepComponent implements OnChanges
showNomineeForm = false;
nomineeError = '';
private recurringDepositsService = inject(RecurringDepositsService);
autoDebitFacility = false;
/**
* Lien Marked products only: how much of this RD may be given as loan, as % of its balance when the loan is taken.
* Starts at the product's percentage; may be lowered, never raised above it.
*/
lienPercentage: number | null = null;
private existingAccountApplied = false;
@@ -50,13 +58,44 @@ export class RecurringDepositsAccountCurrencyStepComponent implements OnChanges
if (Array.isArray(ea.nominees) && ea.nominees.length > 0) {
this.nominees = [...ea.nominees];
}
// The account response does not carry the deposit's own lien percentage; the lien endpoint does.
if (ea.id) {
this.recurringDepositsService.getRecurringDepositLiens(ea.id).subscribe((response: any) => {
this.lienPercentage = response?.lienPercentage ?? response?.productLienPercentage ?? null;
});
}
}
if (changes['recurringDepositsAccountProductTemplate']?.currentValue && !this.existingAccountApplied) {
const pt = changes['recurringDepositsAccountProductTemplate'].currentValue;
this.autoDebitFacility = !!pt.autoDebitFacility;
this.lienPercentage = pt.lienMarked ? (pt.lienPercentage ?? 100) : null;
}
}
get lienAllowed(): boolean {
return !!this.recurringDepositsAccountProductTemplate?.lienMarked;
}
/** The product's ceiling; a product with no percentage allows the whole balance. */
get productLienLimit(): number {
return Number(this.recurringDepositsAccountProductTemplate?.lienPercentage) || 100;
}
/** Why the entered percentage would be refused, or null when it is fine. */
get lienPercentageError(): string | null {
if (!this.lienAllowed) {
return null;
}
if (this.lienPercentage === null || this.lienPercentage === undefined || `${this.lienPercentage}` === '') {
return 'Enter how much of the RD balance can be given as loan.';
}
const value = Number(this.lienPercentage);
if (!(value > 0) || value > this.productLienLimit) {
return `Must be more than 0% and at most ${this.productLienLimit}% (product limit).`;
}
return null;
}
get totalShare(): number {
return this.nominees.reduce((sum, n) => sum + (+n.percentageShare || 0), 0);
}
@@ -112,7 +151,8 @@ export class RecurringDepositsAccountCurrencyStepComponent implements OnChanges
get currencyStepData() {
return {
autoDebitFacility: this.autoDebitFacility
autoDebitFacility: this.autoDebitFacility,
...(this.lienAllowed && this.lienPercentage != null ? { lienPercentage: Number(this.lienPercentage) } : {})
};
}
}
@@ -121,11 +121,30 @@
{{ productTemplate.shareLinkingEnabled ? 'Yes' : 'No' }}
</span>
</div>
<div class="rad-field rad-info-row">
<span class="rad-info-label">Introducer Required</span>
<span class="rad-badge" [class.rad-badge--yes]="productTemplate.introducerRequired">
{{ productTemplate.introducerRequired ? 'Yes' : 'No' }}
</span>
</div>
</div>
</div>
<!-- The product requires an introducer: an existing customer, never the depositor themselves. -->
@if (productTemplate?.introducerRequired) {
<mifosx-introducer-search
[excludedClientIds]="primaryClientId ? [primaryClientId] : []"
[preselectedIntroducer]="
introducerId ? { clientId: introducerId, displayName: introducerName, cifNumber: introducerCif } : null
"
(introducerChange)="onIntroducerChange($event)"
></mifosx-introducer-search>
}
<!-- ══ Deposit Account Link ══════════════════════════════════════════ -->
@if (productTemplate?.shareLinkingEnabled) {
<!-- Also offered when the product allows Auto Debit: that is the account installments are collected from. -->
@if (productTemplate?.shareLinkingEnabled || productTemplate?.autoDebitFacility) {
<div class="dal-card">
<div class="dal-card__header">
<div class="dal-card__header-left">
@@ -24,6 +24,7 @@ import { Dates } from 'app/core/utils/dates';
import { MatDialog } from '@angular/material/dialog';
/** Custom Services */
import { ProductsService } from 'app/products/products.service';
import { RecurringDepositsService } from '../../recurring-deposits.service';
import { ClientsService } from 'app/clients/clients.service';
import {
@@ -62,6 +63,7 @@ import { IntroducerSearchComponent } from 'app/shared/introducer-search/introduc
export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnChanges {
private formBuilder = inject(UntypedFormBuilder);
private recurringDepositsService = inject(RecurringDepositsService);
private productsService = inject(ProductsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private cdr = inject(ChangeDetectorRef);
@@ -117,6 +119,10 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
primaryClientName = '';
primaryClientCif = '';
primaryClientId: number | null = null;
/** Introducer - asked for only when the product has "Introducer Required" on. */
introducerId: number | null = null;
introducerName: string | null = null;
introducerCif: string | null = null;
primaryHolderShare = 50;
// ── Status of Account (RBI) ────────────────────────────────────────────
@@ -453,6 +459,14 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
}
}
this.productTemplate = response;
// The account template leaves out the product's installment limits; the calculator and the submit check
// read them from this template, so they are taken from the product itself.
if (response.minDepositAmount == null && response.maxDepositAmount == null) {
this.productsService.getRecurringDepositProduct(String(productId)).subscribe((product: any) => {
response.minDepositAmount = product?.minDepositAmount ?? null;
response.maxDepositAmount = product?.maxDepositAmount ?? null;
});
}
this.accountTypeOptions = this.extractOptions(response.accountTypeOptions, response.accountType);
this.customerSegmentOptions = this.extractOptions(response.customerSegmentOptions, response.customerSegment);
this.selectedAccountType = response.accountType ?? null;
@@ -463,7 +477,7 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
this.recurringDepositsAccountProductTemplate.emit(response);
this.fieldOfficerData = response.fieldOfficerOptions;
this.isProductSelected = true;
if (response.shareLinkingEnabled) {
if (response.shareLinkingEnabled || response.autoDebitFacility) {
this.loadClientAccounts();
}
this.recurringDepositAccountDetailsForm.patchValue({
@@ -610,6 +624,12 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
return this.dateUtils.formatDate(dateValue, 'M/d/yyyy') || '';
}
onIntroducerChange(event: { clientId: number; displayName: string; cifNumber?: string } | null): void {
this.introducerId = event?.clientId ?? null;
this.introducerName = event?.displayName ?? null;
this.introducerCif = event?.cifNumber ?? null;
}
onGuardianChange(event: { clientId: number; displayName: string; cifNumber?: string } | null): void {
this.guardianClientId = event?.clientId ?? null;
this.guardianName = event?.displayName ?? '';
@@ -795,6 +815,12 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
value.minorDateOfBirth = this.minorDob ? this.dateUtils.formatDate(this.minorDob, 'dd MMMM yyyy') : null;
}
if (this.productTemplate?.introducerRequired && this.introducerId) {
value.introducerId = this.introducerId;
value.introducerName = this.introducerName;
value.introducerCifNumber = this.introducerCif;
}
if (this.selectedLinkedAccount) {
value.linkedAccountId = this.selectedLinkedAccount.id;
value.linkedAccountType = this.selectedLinkedAccount.accountType;
@@ -93,7 +93,13 @@
<div class="fdrc-calc-field">
<label class="fdrc-calc-label">
Monthly Installment
<span class="fdrc-calc-optional">(optional — for maturity estimation)</span>
<span class="fdrc-calc-optional"
>(required@if (minInstallment > 0) {
— min ₹{{ minInstallment }}
}@if (maxInstallment > 0) {
, max ₹{{ maxInstallment }}
})</span
>
</label>
<div class="fdrc-amount-row">
<span class="fdrc-amount-prefix">₹</span>
@@ -257,6 +263,21 @@
</span>
<span class="fdrc-maturity-row__value">{{ matchResult.appliedRate | number: '1.2-2' }}% p.a.</span>
</div>
@if (matchResult.interestCalculation) {
<div class="fdrc-maturity-row">
<span class="fdrc-maturity-row__label">Interest Type</span>
<span class="fdrc-maturity-row__value">
{{ matchResult.interestCalculation }}
@if (matchResult.interestCalculation === 'Compounding') {
({{ interestPeriodLabel(matchResult.compoundingPeriod) }})
}
</span>
</div>
<div class="fdrc-maturity-row">
<span class="fdrc-maturity-row__label">Interest Credited</span>
<span class="fdrc-maturity-row__value">{{ interestPeriodLabel(matchResult.postingPeriod) }}</span>
</div>
}
<div class="fdrc-maturity-row fdrc-maturity-row--interest">
<span class="fdrc-maturity-row__label">Estimated Interest Earned</span>
<span class="fdrc-maturity-row__value">₹ {{ matchResult.interestEarned | number: '1.2-2' }}</span>
@@ -267,6 +288,23 @@
>₹ {{ matchResult.maturityAmount | number: '1.2-2' }}</span
>
</div>
@if (matchResult.postingSchedule?.length) {
<details class="fdrc-credits">
<summary>Interest credits to the RD ({{ matchResult.postingSchedule.length }})</summary>
<table class="fdrc-credits__table">
<tr>
<th>Credited on</th>
<th class="text-right">Interest</th>
</tr>
@for (p of matchResult.postingSchedule; track p.date) {
<tr>
<td>{{ p.date }}</td>
<td class="text-right">₹ {{ p.interest | number: '1.2-2' }}</td>
</tr>
}
</table>
</details>
}
</div>
</div>
}
@@ -1127,3 +1127,31 @@ $red-light: #fee2e2;
font-size: 14px;
}
}
.fdrc-credits {
margin-top: 8px;
font-size: 12px;
summary {
cursor: pointer;
font-weight: 600;
color: #2563eb;
}
&__table {
width: 100%;
margin-top: 6px;
border-collapse: collapse;
th,
td {
padding: 3px 6px;
border-bottom: 1px solid #e2e8f0;
text-align: left;
}
.text-right {
text-align: right;
}
}
}
@@ -42,6 +42,7 @@ import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
import { YesnoPipe } from '../../../../pipes/yesno.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FormsModule } from '@angular/forms';
import { buildRdChart, tenureInMonths } from 'app/products/recurring-deposit-products/rd-product-chart';
export type RdPeriodUnit = 'Days' | 'Months' | 'Years';
@@ -53,6 +54,12 @@ export interface SlabMatchResult {
interestEarned?: number;
tenureDays?: number;
daysInYear?: number;
/** From the product, as the server calculated it: Simple / Compounding, and the period names. */
interestCalculation?: string;
compoundingPeriod?: string;
postingPeriod?: string;
/** Each interest credit the deposit will receive, if every installment is paid on time. */
postingSchedule?: { date: string; interest: number }[];
}
/**
@@ -123,20 +130,62 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
return (this.cbsBaseRates || []).find((r) => r.fdType === 'DOUBLE' && r.isActive);
}
/**
* The product's own chart - the one the server prices the RD from - with each slab's senior-citizen rate taken
* from the CBS base chart it was built from. Falls back to the base chart only when the product has no chart.
*/
get interestRateChartData(): any[] {
if (this.activeBase?.slabs?.length) {
return this.normalizeCbsSlabs(this.activeBase.slabs);
}
const cbsSlabs: any[] = this.activeBase?.slabs || [];
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
if (!template) return [];
return (
template.accountChart?.chartSlabs ??
template.activeChart?.chartSlabs ??
template.interestRateCharts?.[0]?.chartSlabs ??
template.interestRateChart?.chartSlabs ??
template.chartSlabs ??
[]
);
const productSlabs: any[] =
template?.accountChart?.chartSlabs ??
template?.activeChart?.chartSlabs ??
template?.interestRateCharts?.[0]?.chartSlabs ??
template?.interestRateChart?.chartSlabs ??
template?.chartSlabs ??
[];
if (this.chartCache?.cbsSlabs !== cbsSlabs || this.chartCache?.productSlabs !== productSlabs) {
this.chartCache = { cbsSlabs, productSlabs, slabs: this.mergeChart(cbsSlabs, productSlabs) };
}
return this.chartCache.slabs;
}
private chartCache: { cbsSlabs: any[]; productSlabs: any[]; slabs: any[] } | null = null;
/** The product's minimum deposit term in months - the server refuses anything shorter. Null when it sets none. */
/** The product's installment limits (0 = no limit). */
get minInstallment(): number {
return Number(this.recurringDepositsAccountProductTemplate?.minDepositAmount) || 0;
}
get maxInstallment(): number {
return Number(this.recurringDepositsAccountProductTemplate?.maxDepositAmount) || 0;
}
get productMinTenureMonths(): number | null {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return tenureInMonths(template?.minDepositTerm, template?.minDepositTermType?.id);
}
private mergeChart(cbsSlabs: any[], productSlabs: any[]): any[] {
const baseSlabs = this.normalizeCbsSlabs(buildRdChart(cbsSlabs, [], null).slabs);
if (!productSlabs.length) {
return baseSlabs;
}
// Products made before RD charts moved to months still hold the base chart's own year slabs
const candidates = [...baseSlabs, ...this.normalizeCbsSlabs(cbsSlabs)];
return productSlabs.map((slab: any) => {
if (slab.seniorCitizenRate != null) {
return slab;
}
const base = candidates.find(
(candidate: any) =>
candidate.fromPeriod === slab.fromPeriod &&
(candidate.toPeriod ?? null) === (slab.toPeriod ?? null) &&
this.periodLabel(candidate) === this.periodLabel(slab)
);
return base?.seniorCitizenRate != null ? { ...slab, seniorCitizenRate: base.seniorCitizenRate } : slab;
});
}
private normalizeCbsSlabs(slabs: any[]): any[] {
@@ -146,6 +195,10 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
}));
}
private periodLabel(slab: any): string {
return String(slab.periodType?.value ?? slab.periodTypeLabel ?? '').toLowerCase();
}
chartSlabsDisplayedColumns: string[] = [
'period',
'amountRange',
@@ -253,6 +306,28 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
return period; // days
}
/**
* Whether the tenure falls in the slab, counted the way the server counts it: in whole units of the slab's own
* period type, so a 30-month RD is "2 years" against a year slab and lands in "1-2 Years" rather than between
* slabs. Day and week slabs keep the day comparison.
*/
private isTenureInSlab(slab: any, tenureMonths: number, tenureDays: number): boolean {
const label = this.periodLabel(slab) || 'days';
let tenure: number;
let from = slab.fromPeriod ?? 0;
let to = slab.toPeriod ?? Infinity;
if (label.includes('month')) {
tenure = tenureMonths;
} else if (label.includes('year')) {
tenure = Math.floor(tenureMonths / 12);
} else {
tenure = tenureDays;
from = this.slabPeriodToDays(from, label);
to = to === Infinity ? Infinity : this.slabPeriodToDays(to, label);
}
return tenure >= from && tenure <= to;
}
calculateRate() {
this.validationWarning = null;
this.matchResult = null;
@@ -263,6 +338,26 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
}
const enteredDays = this.toDays(this.tenureValue, this.tenurePeriodUnit);
const enteredMonths = this.tenurePeriodUnit === 'Years' ? this.tenureValue * 12 : this.tenureValue;
const minimumMonths = this.productMinTenureMonths;
if (minimumMonths != null && enteredMonths < minimumMonths) {
this.validationWarning = `Minimum RD tenure for this product is ${minimumMonths} months.`;
return;
}
// The installment must be within the product's limits - the same check the submit and the server make, shown
// here so a wrong amount is caught before the rest of the form is filled.
if (!this.principalAmount || this.principalAmount <= 0) {
this.validationWarning = 'Please enter the monthly installment amount.';
return;
}
if (this.minInstallment > 0 && this.principalAmount < this.minInstallment) {
this.validationWarning = `Minimum monthly installment for this product is ₹${this.minInstallment}.`;
return;
}
if (this.maxInstallment > 0 && this.principalAmount > this.maxInstallment) {
this.validationWarning = `Maximum monthly installment for this product is ₹${this.maxInstallment}.`;
return;
}
const slabs: any[] = this.interestRateChartData || [];
if (!slabs.length) {
@@ -275,11 +370,7 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
for (let i = 0; i < slabs.length; i++) {
const slab = slabs[i];
const periodTypeValue = slab.periodType?.value || 'Days';
const fromDays = this.slabPeriodToDays(slab.fromPeriod ?? 0, periodTypeValue);
const toDays = slab.toPeriod != null ? this.slabPeriodToDays(slab.toPeriod, periodTypeValue) : Infinity;
if (enteredDays >= fromDays && enteredDays <= toDays) {
if (this.isTenureInSlab(slab, enteredMonths, enteredDays)) {
matchedSlab = slab;
matchedIndex = i;
break;
@@ -329,10 +420,20 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
tenureInMonths: tenureInMonths,
interestCompoundingPeriodInMonths: compoundingMonths
};
// With the product named, the server calculates with the product's own interest settings (simple/compounding,
// compounding and posting periods, days in year) - the same figure the opened account will carry.
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
const productId = template?.depositProductId ?? template?.productId;
if (productId) {
calculationData.productId = productId;
}
if (this.submittedOnDate) {
const date = new Date(this.submittedOnDate);
calculationData.submittedOnDate = date.toISOString().split('T')[0];
// The local calendar date - toISOString() is UTC, which in IST turns midnight of the 9th into the 8th and
// starts the projection a day early (a different maturity amount from the one the account gets).
const pad = (n: number) => String(n).padStart(2, '0');
calculationData.submittedOnDate = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
calculationData.dateFormat = 'yyyy-MM-dd'; // Standard ISO for API
calculationData.locale = 'en';
}
@@ -341,6 +442,10 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
result.maturityAmount = apiResult.maturityAmount;
result.interestEarned = apiResult.totalInterest;
result.daysInYear = apiResult.daysInYear;
result.interestCalculation = apiResult.interestCalculation;
result.compoundingPeriod = apiResult.compoundingPeriod;
result.postingPeriod = apiResult.postingPeriod;
result.postingSchedule = apiResult.postingSchedule;
this.matchResult = { ...result };
this.emitTenureSelection();
});
@@ -350,6 +455,18 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
}
}
/** "QUATERLY" / "BIANNUAL" / "MONTHLY" (server enum names) as a person would say them. */
interestPeriodLabel(name?: string): string {
const labels: Record<string, string> = {
DAILY: 'Daily',
MONTHLY: 'Monthly',
QUATERLY: 'Quarterly',
BIANNUAL: 'Half-yearly',
ANNUAL: 'Yearly'
};
return name ? (labels[name] ?? name) : '';
}
private emitTenureSelection() {
const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit);
if (frequencyId !== null && this.matchResult) {
@@ -441,76 +558,54 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
// ── Header Getters ──────────────────────────────────────────────────────
get chartName(): string {
if (this.activeBase) {
return this.activeBase.versionLabel || 'CBS Base Interest Rate';
}
/**
* The RD product's own chart - the one the deposit is actually priced on. The CBS base chart (an FD master the
* product's chart was built from) is shown only when the product has no chart of its own.
*/
private get productChart(): any {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return (
template?.accountChart?.name ||
template?.activeChart?.name ||
template?.interestRateCharts?.[0]?.name ||
template?.interestRateChart?.name ||
template?.name ||
''
template?.accountChart ||
template?.activeChart ||
template?.interestRateCharts?.[0] ||
template?.interestRateChart ||
null
);
}
get chartName(): string {
if (this.productChart) {
return this.productChart.name || '';
}
return this.activeBase?.versionLabel || 'CBS Base Interest Rate';
}
get chartFromDate(): any {
if (this.activeBase) {
return this.activeBase.effectiveFromDate;
if (this.productChart) {
return this.productChart.fromDate;
}
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return (
template?.accountChart?.fromDate ||
template?.activeChart?.fromDate ||
template?.interestRateCharts?.[0]?.fromDate ||
template?.interestRateChart?.fromDate ||
template?.fromDate
);
return this.activeBase?.effectiveFromDate;
}
get chartEndDate(): any {
if (this.activeBase) {
return null;
if (this.productChart) {
return this.productChart.endDate;
}
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return (
template?.accountChart?.endDate ||
template?.activeChart?.endDate ||
template?.interestRateCharts?.[0]?.endDate ||
template?.interestRateChart?.endDate ||
template?.endDate
);
return null;
}
get chartDescription(): string {
if (this.activeBase) {
return this.activeBase.remarks || '';
if (this.productChart) {
return this.productChart.description || '';
}
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return (
template?.accountChart?.description ||
template?.activeChart?.description ||
template?.interestRateCharts?.[0]?.description ||
template?.interestRateChart?.description ||
template?.description ||
''
);
return this.activeBase?.remarks || '';
}
get isActive(): boolean {
if (this.activeBase) {
return this.activeBase.isActive;
if (this.productChart) {
return this.productChart.isActive ?? true;
}
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return (
template?.accountChart?.isActive ??
template?.activeChart?.isActive ??
template?.interestRateCharts?.[0]?.isActive ??
template?.interestRateChart?.isActive ??
true
);
return this.activeBase?.isActive ?? true;
}
get isPrimaryGroupingByAmount(): boolean {
@@ -84,25 +84,17 @@
</div>
}
<mat-checkbox class="flex-98 margin-v" labelPosition="before" formControlName="preClosurePenalApplicable">
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}
</mat-checkbox>
@if (closureSettingsForm.value.preClosurePenalApplicable) {
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Penal Interest' | translate }} (%)</mat-label>
<input type="number" matInput formControlName="preClosurePenalInterest" min="0" step="0.01" />
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Period' | translate }}</mat-label>
<mat-select formControlName="preClosurePenalInterestOnTypeId">
@for (preClosurePenalInterestOnType of preClosurePenalInterestOnTypeData; track preClosurePenalInterestOnType) {
<mat-option [value]="preClosurePenalInterestOnType.id">
{{ preClosurePenalInterestOnType.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
</mat-form-field>
}
<!-- The premature-closure penalty is the product's, shown here and never changed per account. -->
<div class="flex-98 margin-v rdmt-penal">
<div class="rdmt-penal__title">{{ 'labels.inputs.Apply Penal Interest (less)' | translate }} <span class="rdmt-penal__tag">From Product</span></div>
@if (closureSettingsForm.value.preClosurePenalApplicable) {
<div class="rdmt-penal__row">
<span>Penal Interest: <strong>{{ closureSettingsForm.value.preClosurePenalInterest }}%</strong></span>
<span>Period: <strong>{{ penalPeriodLabel }}</strong></span>
</div>
<div class="rdmt-penal__hint">On premature closure the rate is reduced by this much.</div>
} @else {
<div class="rdmt-penal__row"><strong>No</strong> - no penal interest is charged on premature closure of this product.</div>
}
</div>
</form>
@@ -150,3 +150,37 @@
}
}
}
.rdmt-penal {
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
font-size: 13px;
&__title {
font-weight: 600;
margin-bottom: 6px;
}
&__tag {
margin-left: 6px;
padding: 1px 6px;
border-radius: 8px;
background: #e0f2fe;
color: #0369a1;
font-size: 10px;
font-weight: 700;
}
&__row {
display: flex;
gap: 24px;
}
&__hint {
margin-top: 4px;
font-size: 11px;
color: #64748b;
}
}
@@ -82,7 +82,7 @@ export class RecurringDepositsAccountMaturityTermsStepComponent implements OnCha
);
this.savingsAccountsData = template.savingsAccounts ?? [];
this.preClosurePenalInterestOnTypeData = template.preClosurePenalInterestOnTypeOptions ?? [];
if (!this.closureSettingsForm.get('preClosurePenalApplicable').dirty) {
{
this.closureSettingsForm.patchValue({
preClosurePenalApplicable: template.preClosurePenalApplicable ?? false,
preClosurePenalInterest: template.preClosurePenalInterest ?? '',
@@ -103,9 +103,21 @@ export class RecurringDepositsAccountMaturityTermsStepComponent implements OnCha
this.closureSettingsForm.get('transferToSavingsId')?.setValue('');
}
/** Chosen maturity/closure settings, ready to merge into the submit payload. */
/** "Whole Term" / "Till Premature Withdrawal", as the product has it. */
get penalPeriodLabel(): string {
const id = this.closureSettingsForm.value.preClosurePenalInterestOnTypeId;
return this.preClosurePenalInterestOnTypeData.find((option: any) => option.id === id)?.value ?? '-';
}
/**
* Chosen maturity/closure settings, ready to merge into the submit payload. The premature-closure penalty is left
* out: it is the product's, which the submit already carries and the server applies regardless.
*/
get closureSettings() {
const settings = this.closureSettingsForm.getRawValue();
delete settings.preClosurePenalApplicable;
delete settings.preClosurePenalInterest;
delete settings.preClosurePenalInterestOnTypeId;
for (const key in settings) {
if (settings[key] === '') {
delete settings[key];
@@ -878,12 +878,12 @@
}
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}</span>
<span class="preview-value">{{ closureSettings?.preClosurePenalApplicable | yesNo }}</span>
<span class="preview-value">{{ productPenal.applicable | yesNo }}</span>
</div>
@if (closureSettings?.preClosurePenalApplicable) {
@if (productPenal.applicable) {
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Penal Interest' | translate }} (%)</span>
<span class="preview-value">{{ closureSettings?.preClosurePenalInterest }}%</span>
<span class="preview-value">{{ productPenal.interest }}%</span>
</div>
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Period' | translate }}</span>
@@ -218,10 +218,20 @@ export class RecurringDepositsAccountPreviewStepComponent implements OnChanges {
return match ? `(${match.accountNo}) ${match.savingsProductName}` : '—';
}
/** The premature-closure penalty is the product's (the account cannot change it), so it is shown from the product. */
get productPenal(): { applicable: boolean; interest: any; typeId: any } {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return {
applicable: !!template?.preClosurePenalApplicable,
interest: template?.preClosurePenalInterest,
typeId: template?.preClosurePenalInterestOnType?.id
};
}
get closurePenalInterestOnTypeLabel(): string {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
const options = template?.preClosurePenalInterestOnTypeOptions ?? [];
const match = options.find((o: any) => o.id === this.closureSettings?.preClosurePenalInterestOnTypeId);
const match = options.find((o: any) => o.id === this.productPenal.typeId);
return match?.value ?? '—';
}
@@ -200,6 +200,27 @@
}}
</span>
</div>
@if (lienLimit) {
<div class="nx-row">
<span class="nx-row__label">Loan against RD ({{ lienLimit.percentage }}% of balance)</span>
<span class="nx-row__value nx-row__value--amount">
up to {{ lienLimit.maxLoanAmount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}, available
{{ lienLimit.availableForLoan | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
}
@if (totalLienAmount > 0) {
<div class="nx-row">
<span class="nx-row__label">Lien Marked (loan security)</span>
<span class="nx-row__value nx-row__value--amount">
{{ totalLienAmount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
-
@for (lien of liens; track lien.loanId; let last = $last) {
Loan {{ lien.loanAccountNo }}{{ last ? '' : ', ' }}
}
</span>
</div>
}
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Recurring Deposit Amount' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
@@ -15,6 +15,7 @@ import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RecurringDepositsService } from '../../recurring-deposits.service';
@Component({
selector: 'mifosx-general-tab',
@@ -34,14 +35,22 @@ export class GeneralTabComponent {
private route = inject(ActivatedRoute);
private router = inject(Router);
private recurringDepositsService = inject(RecurringDepositsService);
recurringDepositsAccountData: any;
isprematureAllowed = false;
entityType: string;
currency: Currency;
/** Loans this deposit secures; empty when it is free of lien. */
liens: any[] = [];
totalLienAmount = 0;
/** Loan limit today (Lien Marked products only): % that applies, what it comes to, and what is left. */
lienLimit: { percentage: number; maxLoanAmount: number; availableForLoan: number } | null = null;
constructor() {
this.route.parent.data.subscribe((data: { recurringDepositsAccountData: any }) => {
this.recurringDepositsAccountData = data.recurringDepositsAccountData;
this.loadLiens(this.recurringDepositsAccountData?.id);
this.currency = this.recurringDepositsAccountData.currency;
this.isprematureAllowed = data.recurringDepositsAccountData.maturityDate != null;
if (this.router.url.includes('clients')) {
@@ -53,4 +62,24 @@ export class GeneralTabComponent {
}
});
}
private loadLiens(accountId: any) {
this.liens = [];
this.totalLienAmount = 0;
this.lienLimit = null;
if (!accountId) {
return;
}
this.recurringDepositsService.getRecurringDepositLiens(accountId).subscribe((response: any) => {
this.liens = response?.liens ?? [];
this.totalLienAmount = Number(response?.totalLienAmount) || 0;
this.lienLimit = response?.lienAllowed
? {
percentage: Number(response.effectiveLienPercentage) || 0,
maxLoanAmount: Number(response.maxLoanAmount) || 0,
availableForLoan: Number(response.availableForLoan) || 0
}
: null;
});
}
}
@@ -82,6 +82,20 @@
<span class="rd-kpi__label">{{ 'labels.inputs.Deposit Period' | translate }}</span>
<span class="rd-kpi__value">{{ recurringDepositsAccountData.depositPeriod | number }}</span>
</div>
<!-- Interest credited to the deposit so far - by its product's posting period (monthly, quarterly...), plus the
remainder on the maturity date. -->
<div class="rd-kpi rd-kpi--interest">
<span class="rd-kpi__label">Interest Posted</span>
<span class="rd-kpi__value">
{{
(recurringDepositsAccountData.summary.totalInterestPosted || 0)
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
@if (recurringDepositsAccountData.interestPostingPeriodType?.value) {
<span class="rd-kpi__sub">Credited {{ recurringDepositsAccountData.interestPostingPeriodType.value }}</span>
}
</div>
</div>
}
@@ -212,6 +212,17 @@ $border: #e2e8f0;
background: rgb(139 92 246 / 22%);
border-color: rgb(139 92 246 / 35%);
}
&--interest {
background: rgb(16 185 129 / 22%);
border-color: rgb(16 185 129 / 35%);
}
&__sub {
margin-top: 1px;
font-size: 9.5px;
color: rgb(255 255 255 / 75%);
}
}
/* Status pill */
@@ -40,6 +40,10 @@ import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs';
import { StatusLookupPipe } from '../../../pipes/status-lookup.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component';
import { ProductsService } from 'app/products/products.service';
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
import { AlertService } from 'app/core/alert/alert.service';
/**
* RecurringDeposits Account View Component
@@ -79,6 +83,8 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private authenticationService = inject(AuthenticationService);
private productsService = inject(ProductsService);
private alertService = inject(AlertService);
dialog = inject(MatDialog);
private translateService = inject(TranslateService);
@@ -105,6 +111,7 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
'Enable Withhold Tax': 'interest',
'Disable Withhold Tax': 'interest',
'Premature Close': 'danger',
'Extend Tenure': 'admin',
'Undo Activation': 'admin',
Close: 'danger',
'Modify Application': 'admin',
@@ -128,6 +135,7 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
'Enable Withhold Tax': 'percentage',
'Disable Withhold Tax': 'percentage',
'Premature Close': 'times-circle',
'Extend Tenure': 'calendar-alt',
'Undo Activation': 'undo',
Close: 'times-circle',
'Modify Application': 'edit',
@@ -184,6 +192,30 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
ngOnInit() {
this.setConditionalButtons();
this.applyProductRules();
}
/**
* Buttons the deposit's product decides: no Premature Close unless the product has "Premature Withdrawal option" on,
* and an Extend Tenure action when it has "Tenure Flexibility" on. The server enforces both as well.
*/
private applyProductRules() {
const productId = this.recurringDepositsAccountData.depositProductId;
if (!productId) {
return;
}
this.productsService.getRecurringDepositProduct(String(productId)).subscribe((product: any) => {
if (!product?.prematureWithdrawalOption) {
this.buttonConfig.buttonsArray = this.buttonConfig.buttonsArray.filter((btn) => btn.name !== 'Premature Close');
this.buttonConfig.optionArray = this.buttonConfig.optionArray.filter((opt) => opt.name !== 'Premature Close');
}
if (product?.tenureFlexibility && this.recurringDepositsAccountData.status?.value === 'Active') {
this.buttonConfig.addOption({
name: 'Extend Tenure',
taskPermissionName: 'EXTENDTENURE_RECURRINGDEPOSITACCOUNT'
});
}
});
}
/**
@@ -312,9 +344,61 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
case 'Terminate':
this.terminateRecurringDepositsAccount();
break;
case 'Extend Tenure':
this.extendTenure();
break;
}
}
/**
* Lengthens the deposit's tenure (product has Tenure Flexibility on). New installments are added after the current
* last one, so Auto Debit keeps collecting through the added months; the rate becomes the chart's rate for the new
* tenure and the maturity date and amount are worked out again.
*/
private extendTenure() {
const current = Number(this.recurringDepositsAccountData.depositPeriod) || 0;
const unit = this.recurringDepositsAccountData.depositPeriodFrequency?.value || 'Months';
const data = {
title: 'Extend Tenure',
layout: { addButtonText: 'Extend' },
formfields: [
new InputBase({
controlName: 'depositPeriod',
label: `New tenure in ${unit} (now ${current})`,
value: current + 1,
type: 'number',
required: true,
min: current + 1,
order: 1
})
]
};
this.dialog
.open(FormDialogComponent, { data })
.afterClosed()
.subscribe((response: any) => {
const newPeriod = Number(response?.data?.value?.depositPeriod);
if (!response?.data) {
return;
}
if (!newPeriod || newPeriod <= current) {
this.alertService.alert({ type: 'Extend Tenure', message: `The new tenure must be more than ${current} ${unit}.` });
return;
}
this.recurringDepositsService
.executeRecurringDepositsAccountCommand(this.recurringDepositsAccountData.id, 'extendTenure', {
depositPeriod: newPeriod,
locale: this.settingsService.language.code
})
.subscribe((result: any) => {
if (result?.commandId && !result?.changes) {
this.alertService.alert({ type: 'Extend Tenure', message: 'Tenure extension submitted for approval.' });
}
this.reload();
});
});
}
/**
* Deletes Recurring Deposits Account.
*/
@@ -36,12 +36,9 @@ export class RecurringDepositsButtonsConfiguration {
setButtons(status: string) {
switch (status) {
case 'Active':
// No Deposit button: a direct deposit here skips the manager's approval. RD installments come in
// through the cashier's Receipt (maker-checker) or Auto Debit.
this.buttonsArray = [
{
name: 'Deposit',
icon: 'fa fa-arrow-up',
taskPermissionName: 'DEPOSIT_RECURRINGDEPOSITACCOUNT'
},
{
name: 'Withdrawal',
icon: 'fa fa-arrow-down',
@@ -103,12 +100,9 @@ export class RecurringDepositsButtonsConfiguration {
name: 'Calculate Interest',
icon: 'fa fa-calculator',
taskPermissionName: 'CALCULATEINTEREST_RECURRINGDEPOSITACCOUNT'
},
{
name: 'Post Interest',
icon: 'fa fa-money',
taskPermissionName: 'POSTINTEREST_RECURRINGDEPOSITACCOUNT'
}
// No Post Interest: the term's interest is credited by the server on the maturity date, and again only at
// closure for any days after it.
];
break;
case 'Rejected':
@@ -31,6 +31,14 @@ export class RecurringDepositsService {
return this.http.get(`/recurringdepositaccounts/${recurringDepositAccountId}`, { params: httpParams });
}
/**
* Loans this recurring deposit currently secures (lien), with the amount each one holds.
* Returns { totalLienAmount, liens: [{ loanId, loanAccountNo, loanStatus, amount }] }.
*/
getRecurringDepositLiens(recurringDepositAccountId: any): Observable<any> {
return this.http.get(`/recurringdepositaccounts/${recurringDepositAccountId}/liens`);
}
/**
* @param clientId Client Id
* @param clientName Client Name
@@ -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,9 +18,16 @@
<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>Reporting Fortnight (45-day lag)</span>
<strong>{{ reportingDateStr || '—' }}</strong>
<span>
Fortnight close
@if (baseDateStr) {
&middot; charged on {{ baseDateStr }}
}
</span>
</div>
<a
class="rbi-circular-btn"
@@ -42,31 +49,44 @@
<div class="summary-row">
<div class="summary-card sc-demand">
<div class="sc-label">Total Demand Liabilities</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 class="sc-value">{{ crore(totalDemand) }}<span class="cr" *ngIf="totalDemand !== null">Cr</span></div>
<div class="sc-sub">Payable on demand</div>
</div>
<div class="summary-card sc-time">
<div class="sc-label">Total Time Liabilities</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 class="sc-value">{{ crore(totalTime) }}<span class="cr" *ngIf="totalTime !== null">Cr</span></div>
<div class="sc-sub">Payable on maturity</div>
</div>
<div class="summary-card sc-asset">
<div class="sc-label">Assets with Banking System</div>
<div class="sc-value">&#8377;8,240<span class="cr">Cr</span></div>
<div class="sc-value">
{{ crore(totalBankingAssets) }}<span class="cr" *ngIf="totalBankingAssets !== null">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">&#8377;69,221<span class="cr">Cr</span></div>
<div class="sc-value">{{ crore(ndtl) }}<span class="cr" *ngIf="ndtl !== null">Cr</span></div>
<div class="sc-sub">After deducting banking assets</div>
</div>
<div class="summary-card sc-crr">
<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>
<!-- 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>
</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'">
@@ -124,13 +144,21 @@
</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 (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 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></td>
</tr>
</tfoot>
@@ -179,13 +207,21 @@
</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 (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 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></td>
</tr>
</tfoot>
@@ -228,15 +264,23 @@
</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">7,600.70</td>
<td class="right foot-asset">8,240.20</td>
<td class="right up">+639.50</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></td>
</tr>
</tfoot>
@@ -285,13 +329,21 @@
</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">12,511.70</td>
<td class="right foot-slr">13,238.80</td>
<td class="right up">+727.10</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></td>
</tr>
</tfoot>
@@ -842,3 +842,30 @@ $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,13 +6,30 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { Component, OnInit, 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',
@@ -29,477 +46,230 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
FaIconComponent
]
})
export class CrrSlrDialogComponent {
export class CrrSlrDialogComponent implements OnInit {
private dialogRef = inject(MatDialogRef<CrrSlrDialogComponent>);
private rbi = inject(RbiCircularsService);
activeTab: 'demand' | 'time' | 'assets' | 'slr' = 'demand';
/** Reporting fortnight date — 45 days before current date */
today = new Date();
reportingDate: Date;
reportingDateStr: string;
/** The date the figures were struck at, as the return itself reports it. */
reportingDateStr = '';
/** 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
/**
* 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 = '';
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'
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.';
}
});
}
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'
}
];
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'
/**
* 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'
});
}
];
return rows;
}
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'
}
];
/**
* 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)) : '—';
}
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'
/** 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 '—';
}
];
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')
);
}
close(): void {
this.dialogRef.close();
@@ -28,17 +28,30 @@
<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 (3%)</span>
<span class="metric-value">&#8377;2,077 Cr</span>
<span class="metric-label">CRR Required{{ crrSlr.crrRate === null ? '' : ' (' + crrSlr.crrRate + '%)' }}</span>
<span class="metric-value">{{ crrSlr.crrRequired }}</span>
</div>
<div class="metric-row">
<span class="metric-label">SLR Required (18%)</span>
<span class="metric-value">&#8377;12,460 Cr</span>
<span class="metric-label">SLR Required{{ crrSlr.slrRate === null ? '' : ' (' + crrSlr.slrRate + '%)' }}</span>
<span class="metric-value">{{ crrSlr.slrRequired }}</span>
</div>
</div>
<div class="mini-sub"><span class="tag-success">Within Limits</span></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>
<!-- CRAR -->
@@ -444,6 +444,17 @@ $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,6 +19,7 @@ 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.
*/
@@ -40,9 +41,29 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
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 */
@@ -67,6 +88,40 @@ 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
});
}
/**
@@ -22,9 +22,134 @@
<input type="date" [(ngModel)]="postingDate" (change)="load()" />
</label>
<button class="ipr-btn" (click)="load()" [disabled]="loading"><fa-icon icon="undo"></fa-icon> Reload</button>
<button class="ipr-btn ipr-btn--primary" (click)="openPreview()" [disabled]="previewLoading">
<fa-icon icon="eye"></fa-icon> Preview interest (today)
</button>
</div>
</div>
@if (isHeadOffice) {
<div class="ipr-tabs">
<button class="ipr-tab" [class.is-active]="tab === 'own'" (click)="switchTab('own')">
<fa-icon icon="building"></fa-icon> My branch{{ myBranch ? ' — ' + myBranch : '' }}
</button>
<button class="ipr-tab" [class.is-active]="tab === 'others'" (click)="switchTab('others')">
<fa-icon icon="sitemap"></fa-icon> Other branches
</button>
</div>
@if (tab === 'others') {
<div class="ipr-alert ipr-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 (showPreview) {
<div class="ipr-card">
<div class="ipr-detail-head">
<div>
<h3>Interest preview — earned up to {{ previewAsOf }}</h3>
<div class="muted">
What each account would be paid if interest were posted today. View only — nothing is saved, approved or
posted.
</div>
</div>
<div class="ipr-total">
<span>Total so far</span>
<strong>₹{{ previewTotal | number: '1.2-2' }}</strong>
<span class="muted">{{ previewRows.length }} account(s)</span>
</div>
</div>
<div class="ipr-preview-actions">
<button class="ipr-btn ipr-btn--slim" (click)="openPreview()" [disabled]="previewLoading">
<fa-icon icon="undo"></fa-icon> Refresh
</button>
<button
class="ipr-btn ipr-btn--slim"
(click)="exportPreview()"
[disabled]="previewLoading || previewRows.length === 0"
>
<fa-icon icon="file-excel"></fa-icon> Export Excel
</button>
<button class="ipr-btn ipr-btn--slim" (click)="closePreview()"><fa-icon icon="times"></fa-icon> Close</button>
</div>
@if (previewLoading) {
<div class="ipr-empty">Calculating interest up to today…</div>
} @else {
<table class="ipr-table">
<thead>
<tr>
<th>Branch</th>
<th>Product</th>
<th>Posting date</th>
<th class="num">Accounts</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>{{ g.postingDate }}</td>
<td class="num">{{ g.accounts }}</td>
<td class="num">₹{{ g.interest | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="ipr-empty">No savings account has interest accrued in its current cycle.</td>
</tr>
}
</tbody>
</table>
@if (previewRows.length > 0) {
<table class="ipr-table">
<thead>
<tr>
<th>Branch</th>
<th>Product</th>
<th>Account no</th>
<th>Account holder</th>
<th class="num">Balance</th>
<th class="num">Rate %</th>
<th>Cycle</th>
<th class="num">Interest so far</th>
</tr>
</thead>
<tbody>
@for (r of previewRows; track r.savingsAccountId) {
<tr>
<td>{{ r.officeName }}</td>
<td>{{ r.productName }}</td>
<td>{{ r.accountNo }}</td>
<td>{{ r.clientName }}</td>
<td class="num">₹{{ r.balance | number: '1.2-2' }}</td>
<td class="num">{{ r.rate | number: '1.2-2' }}</td>
<td class="muted">{{ r.cycleStart }} → {{ r.postingDate }}</td>
<td class="num">₹{{ r.interest | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="7"><strong>Total</strong></td>
<td class="num">
<strong>₹{{ previewTotal | number: '1.2-2' }}</strong>
</td>
</tr>
</tfoot>
</table>
}
}
</div>
}
@if (errorMessage) {
<div class="ipr-alert ipr-alert--error">
<fa-icon icon="exclamation-triangle"></fa-icon>
@@ -52,9 +177,13 @@
@for (d of due; track d.productId + '-' + d.officeId) {
<div class="ipr-due-row">
<strong>{{ d.productName }}</strong> <span>· {{ d.officeName }} · {{ d.postingDate }}</span>
<button class="ipr-btn ipr-btn--primary ipr-btn--slim" (click)="generate(d)" [disabled]="busy">
<fa-icon icon="sync"></fa-icon> Generate report
</button>
@if (d.canAct) {
<button class="ipr-btn ipr-btn--primary ipr-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="ipr-blocked">{{ d.blockedReason }}</span>
}
</div>
}
</div>
@@ -114,9 +243,12 @@
}
@if (canApprove(r)) {
<button class="ipr-btn ipr-btn--primary ipr-btn--slim" (click)="approve(r)" [disabled]="busy">
<fa-icon icon="check"></fa-icon> Approve
<fa-icon icon="check"></fa-icon> {{ tab === 'others' ? 'Approve on behalf' : 'Approve' }}
</button>
}
@if (!r.canAct && r.blockedReason && (r.status === 'GENERATED' || r.status === 'APPROVED')) {
<span class="ipr-blocked">{{ r.blockedReason }}</span>
}
</td>
</tr>
} @empty {
@@ -274,3 +274,54 @@
font-weight: 600;
color: var(--i-amber);
}
// Preview: refresh / export / close, above the totals.
.ipr-preview-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
padding: 0 16px 10px;
}
.ipr-tabs {
display: flex;
gap: 4px;
margin-bottom: 12px;
border-bottom: 1px solid var(--i-g200);
}
.ipr-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
margin-bottom: -1px;
font-size: 13px;
font-weight: 600;
color: var(--i-g500);
cursor: pointer;
background: transparent;
border: 1px solid transparent;
border-radius: 6px 6px 0 0;
&.is-active {
color: var(--i-navy);
background: #fff;
border-color: var(--i-g200);
border-bottom-color: #fff;
}
}
.ipr-alert--info {
background: var(--i-blue-l);
color: var(--i-blue);
}
.ipr-blocked {
display: inline-block;
max-width: 320px;
font-size: 11px;
line-height: 1.35;
color: var(--i-g500);
white-space: normal;
}
@@ -11,11 +11,14 @@ 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 {
InterestPostingDue,
InterestPostingReport,
InterestPostingReportDetail,
InterestPostingReportService
InterestPostingReportService,
InterestPreviewRow,
ReportScope
} from './interest-posting-report.service';
/**
@@ -51,15 +54,48 @@ export class InterestPostingReportComponent implements OnInit {
/** Products with interest due and no report yet — the noon job ran before they had anything due, or failed. */
due: InterestPostingDue[] = [];
/** 'own' — the user's own branch; 'others' — every other branch, a head-office-only tab. */
tab: ReportScope = 'own';
isHeadOffice = false;
myBranch = '';
ngOnInit(): void {
this.postingDate = this.businessDateIso();
this.reportService.context().subscribe({
next: (ctx) => {
this.isHeadOffice = !!ctx?.headOffice;
this.myBranch = ctx?.officeName ?? '';
},
error: () => (this.isHeadOffice = false)
});
this.load();
}
switchTab(tab: ReportScope): 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 interest posting report on its behalf, ` +
'from the head office. Continue?'
);
}
load(): void {
this.loading = true;
this.errorMessage = '';
this.reportService.list(this.postingDate || undefined).subscribe({
this.reportService.list(this.postingDate || undefined, undefined, undefined, this.tab).subscribe({
next: (reports) => {
this.reports = reports ?? [];
this.loading = false;
@@ -75,13 +111,16 @@ export class InterestPostingReportComponent implements OnInit {
this.errorMessage = this.messageOf(err, 'Could not load the interest posting reports.');
}
});
this.reportService.dueWithoutReport(this.postingDate || undefined).subscribe({
this.reportService.dueWithoutReport(this.postingDate || undefined, this.tab).subscribe({
next: (due) => (this.due = due ?? []),
error: () => (this.due = [])
});
}
generate(item: InterestPostingDue): void {
if (!this.confirmOnBehalf(item.officeName, 'generate')) {
return;
}
this.busy = true;
this.notice = '';
this.errorMessage = '';
@@ -111,7 +150,8 @@ export class InterestPostingReportComponent implements OnInit {
if (
!confirm(
`Regenerate the interest posting report for ${report.productName} — ${report.officeName}? It will need approving again.`
)
) ||
!this.confirmOnBehalf(report.officeName, 'regenerate')
) {
return;
}
@@ -138,7 +178,8 @@ export class InterestPostingReportComponent implements OnInit {
!confirm(
`Approve interest of ₹${report.totalInterest} across ${report.accountCount} account(s) for ${report.productName}? ` +
'It will be posted when the branch closes the day.'
)
) ||
!this.confirmOnBehalf(report.officeName, 'approve')
) {
return;
}
@@ -177,8 +218,165 @@ export class InterestPostingReportComponent implements OnInit {
);
}
/** Pending reports that block this user's own day close; another branch's pending reports block only that branch. */
get pendingCount(): number {
return this.reports.filter((r) => r.status === 'GENERATED').length;
return this.tab === 'own' ? this.reports.filter((r) => r.status === 'GENERATED').length : 0;
}
// ── Preview: what each account has earned so far, as if posted today. View only. ──
showPreview = false;
previewLoading = false;
previewRows: InterestPreviewRow[] = [];
previewAsOf = '';
openPreview(): void {
this.showPreview = true;
this.previewLoading = true;
this.errorMessage = '';
this.reportService.preview().subscribe({
next: (rows) => {
this.previewRows = rows ?? [];
this.previewAsOf = this.previewRows[0]?.asOf ?? this.businessDateIso();
this.previewLoading = false;
},
error: (err) => {
this.previewLoading = false;
this.previewRows = [];
this.errorMessage = this.messageOf(err, 'Could not load the 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 and product, for the summary above the account list and the export. */
get previewGroups(): {
officeName: string;
productName: string;
postingDate: string;
accounts: number;
interest: number;
}[] {
const map = new Map<
string,
{ officeName: string; productName: string; postingDate: 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,
postingDate: r.postingDate,
accounts: 0,
interest: 0
};
g.accounts += 1;
g.interest += Number(r.interest || 0);
map.set(key, g);
});
return [...map.values()];
}
exportPreview(): void {
const stamp = `Interest earned to ${this.previewAsOf} — preview, not posted`;
exportWorkbook(
[
{
sheetName: 'By account',
title: 'Savings Interest Preview',
subtitle: stamp,
headers: [
'Branch',
'Product',
'Account No',
'Customer',
'Balance',
'Rate %',
'Cycle Start',
'Posting Date',
'Interest So Far'
],
widths: [
18,
22,
20,
26,
16,
8,
12,
12,
16
],
rows: [
...this.previewRows.map((r) => [
r.officeName,
r.productName,
r.accountNo,
r.clientName,
Number(r.balance || 0),
Number(r.rate || 0),
r.cycleStart,
r.postingDate,
Number(r.interest || 0)
]),
[
'TOTAL',
'',
'',
'',
'',
'',
'',
'',
this.previewTotal
]
]
},
{
sheetName: 'By branch & product',
title: 'Savings Interest Preview — Totals',
subtitle: stamp,
headers: [
'Branch',
'Product',
'Posting Date',
'Accounts',
'Interest So Far'
],
widths: [
18,
22,
12,
10,
16
],
rows: [
...this.previewGroups.map((g) => [
g.officeName,
g.productName,
g.postingDate,
g.accounts,
g.interest
]),
[
'TOTAL',
'',
'',
this.previewRows.length,
this.previewTotal
]
]
}
],
`savings-interest-preview-${this.previewAsOf}`
);
}
private businessDateIso(): string {
@@ -12,6 +12,9 @@ import { Observable } from 'rxjs';
export type InterestPostingReportStatus = 'GENERATED' | 'APPROVED' | 'POSTED' | 'SUPERSEDED';
/** Which branches a list covers: the user's own, or (head office only) every other branch. */
export type ReportScope = 'own' | 'others';
export interface InterestPostingReport {
id: number;
productId: number;
@@ -32,6 +35,8 @@ export interface InterestPostingReport {
failureReason: string | null;
/** Whether the signed-in user may regenerate or approve it: their own branch, or the head office for a branch that did not open. */
canAct: boolean;
/** Why not, where canAct is false. */
blockedReason: string | null;
}
export interface InterestPostingReportLine {
@@ -60,6 +65,8 @@ export interface InterestPostingDue {
officeId: number;
officeName: string;
postingDate: string;
canAct: boolean;
blockedReason: string | null;
}
export interface InterestPostingSummaryRow {
@@ -78,6 +85,22 @@ export interface InterestPostingSummaryRow {
postedAt: string | null;
}
export interface InterestPreviewRow {
officeId: number;
officeName: string;
productId: number;
productName: string;
savingsAccountId: number;
accountNo: string;
clientName: string;
balance: number;
rate: number;
cycleStart: string;
postingDate: string;
asOf: string;
interest: number;
}
/**
* The savings interest posting reports, one per branch and product: generated at noon on a posting
* date, approved by the branch, posted when the branch closes the day.
@@ -87,8 +110,15 @@ export class InterestPostingReportService {
private http = inject(HttpClient);
private readonly base = '/savingsinterestpostingreports';
list(postingDate?: string, status?: string, officeId?: number): Observable<InterestPostingReport[]> {
/** scope 'own' is the user's own branch; 'others' (head office only) every other branch. */
list(
postingDate?: string,
status?: string,
officeId?: number,
scope?: ReportScope
): Observable<InterestPostingReport[]> {
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));
@@ -96,12 +126,18 @@ export class InterestPostingReportService {
}
/** Branch and product pairs with interest due by the date and no report yet, that this user may generate. */
dueWithoutReport(postingDate?: string): Observable<InterestPostingDue[]> {
dueWithoutReport(postingDate?: string, scope?: ReportScope): Observable<InterestPostingDue[]> {
let params = new HttpParams();
if (scope) params = params.set('scope', scope);
if (postingDate) params = params.set('postingDate', postingDate);
return this.http.get<InterestPostingDue[]>(`${this.base}/due-without-report`, { params });
}
/** The signed-in user's branch, and whether it is the head office. */
context(): Observable<{ officeId: number; officeName: string; headOffice: boolean }> {
return this.http.get<{ officeId: number; officeName: string; headOffice: boolean }>(`${this.base}/context`);
}
detail(reportId: number): Observable<InterestPostingReportDetail> {
return this.http.get<InterestPostingReportDetail>(`${this.base}/${reportId}`);
}
@@ -126,6 +162,13 @@ export class InterestPostingReportService {
);
}
/** Interest each account has earned so far this cycle, as if posted today. View only. */
preview(officeId?: number): Observable<InterestPreviewRow[]> {
let params = new HttpParams();
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<InterestPreviewRow[]>(`${this.base}/preview`, { params });
}
/** The head office's branch-wise view over a date range. */
summary(fromDate?: string, toDate?: string, productId?: number): Observable<InterestPostingSummaryRow[]> {
let params = new HttpParams();
@@ -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' : 'Form 9' }} cannot compute</span>
<span class="rrc-rate-now__since">{{ type === 'CRR' ? 'Form 1 Part B' : 'Form 1 Part C' }} cannot compute</span>
}
</div>
}
@@ -62,8 +62,10 @@
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-rate-form__type">
<mat-label>Rate</mat-label>
<mat-select [(ngModel)]="newRate.rateType">
<mat-option value="CRR">CRR — Form 1</mat-option>
<mat-option value="SLR">SLR — Form 9</mat-option>
<!-- 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>
<!-- 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>
@@ -228,11 +230,29 @@
</tr>
</thead>
<tbody>
@for (mapping of selection[role.configKey]; track $index; let i = $index) {
<!-- Tracked by the row's own id, never by position. With $index, deleting a row shifted every
row after it up one, Angular reused the inputs bound to the old positions, and
ngModel wrote their values back into the shortened array - the row reappeared and
Save stayed grey. -->
@for (mapping of selection[role.configKey]; track mapping.rowId; let i = $index) {
<tr [class.rrc-map__row--invalid]="isRowInvalid(mapping)">
<td>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-map__field">
<mat-select [(ngModel)]="mapping.glAccountId" [disabled]="savingKey === role.configKey">
<!-- The chart runs to a hundred accounts, so the list is typed into rather
than scrolled. Cleared as the dropdown opens, so each one opens whole. -->
<mat-select
[(ngModel)]="mapping.glAccountId"
[disabled]="savingKey === role.configKey"
(openedChange)="accountSearch = ''"
>
<mat-option>
<ngx-mat-select-search
[(ngModel)]="accountSearch"
[ngModelOptions]="{ standalone: true }"
placeholderLabel="Search by GL code or name"
noEntriesFoundLabel="No account matches"
></ngx-mat-select-search>
</mat-option>
@for (o of availableAccounts(role, i); track o.id) {
<mat-option [value]="o.id">{{ o.glCode }} · {{ o.name }}</mat-option>
}
@@ -7,8 +7,9 @@
*/
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { catchError, forkJoin, of } from 'rxjs';
import { NgxMatSelectSearchModule } from 'ngx-mat-select-search';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { OrganizationService } from '../organization.service';
@@ -20,11 +21,23 @@ export interface ReserveMapping {
glAccountId: number;
weightPercentage: number;
liabilityClass: string | null;
/**
* Identity for the row, so the template can track rows by it rather than by position.
*
* Tracking by index made deletion impossible: removing a row shifts every row after it up one, so
* Angular reused the inputs that were bound to the old positions and ngModel wrote their values
* straight back into the shortened array. The row reappeared, the draft matched what was stored
* again, and Save stayed grey. Never sent to the backend.
*/
rowId: number;
}
/**
* Configuration behind RBI Form 1 (CRR) and Form 9 (SLR): the percentages RBI sets, and which GL
* accounts the returns read their figures from.
* 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.
*
* 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
@@ -40,15 +53,17 @@ export interface ReserveMapping {
styleUrls: ['./rbi-reserve-config.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
FormsModule,
ReactiveFormsModule,
NgxMatSelectSearchModule
]
})
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
* and Form 9, 'OSS1', 'OSS2'. Null shows every role.
* 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.
*
* 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,
@@ -65,20 +80,45 @@ export class RbiReserveConfigComponent implements OnInit {
roleGroups: { name: string; label: string; roles: any[] }[] = [];
/**
* Which return's configuration is on screen: Form 1, Form 9, or both together.
* Which return's configuration is on screen: Form 1, Form IX, or both together.
*
* 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.
* 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.
*/
formFilter: 'ALL' | 'FORM_1' | 'FORM_9' | 'FORM_IX' = 'ALL';
formFilter: 'ALL' | 'FORM_1' | 'FORM_IX' = 'ALL';
/** Every reserve role the backend returned, before the form filter narrows it. */
private allRoles: any[] = [];
/** Re-slices what is on screen. No round trip - the full set is already held. */
/** Hands each mapping row an identity the template can track it by. See ReserveMapping.rowId. */
private nextRowId = 1;
/**
* What is typed into the search box inside an open account dropdown.
*
* One field for the whole screen rather than one per row: only one dropdown is open at a time, and
* it is cleared as each one opens, so a term left behind cannot narrow the next list a user opens
* without them seeing why it is short.
*/
accountSearch = '';
/**
* Re-slices what is on screen. No round trip - the full set is already held.
*
* The rows are rebuilt for whatever the new slice brings in. They used not to be: selection was
* filled once, for the heads the first slice happened to contain, so switching the filter showed
* the newly admitted heads with no rows at all - a card with a name and an empty body, reading as
* a head that had nothing mapped when it was simply never read.
*/
onFormFilterChange(): void {
this.glRoles = this.rolesForScreen();
for (const role of this.glRoles) {
if (!this.selection[role.configKey]) {
this.reloadSelection(role);
}
}
this.rebuildGroups();
}
@@ -95,20 +135,28 @@ 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 (fortnightly)' },
{ value: 'FORM_9', label: 'Form 9 — SLR (monthly)' },
{ value: 'FORM_1', label: 'Form 1 — CRR & SLR' },
{ value: 'FORM_IX', label: 'Form IX — Assets & Liabilities (R408)' }
];
/**
* Whether a head belongs to the return currently selected.
*
* 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.
* 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.
*/
private belongsToSelectedForm(role: any): boolean {
if (this.formFilter === 'ALL') {
@@ -125,19 +173,7 @@ export class RbiReserveConfigComponent implements OnInit {
if (this.formFilter === 'FORM_IX') {
return false;
}
// 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.
// Everything left belongs to Form 1, whichever of its two parts it settles.
return true;
}
@@ -171,15 +207,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 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.
* 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.
*/
readonly roleUsages = [
{ 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)' },
{ 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)' },
// 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)' },
@@ -230,11 +266,7 @@ export class RbiReserveConfigComponent implements OnInit {
for (const role of this.glRoles) {
// Read back what is stored rather than re-derived: a weight or a class the administrator set
// deliberately would otherwise be replaced by a default on the next save.
this.selection[role.configKey] = (role.accounts || []).map((a: any) => ({
glAccountId: a.glAccountId,
weightPercentage: a.weightPercentage === null || a.weightPercentage === undefined ? 100 : +a.weightPercentage,
liabilityClass: a.liabilityClass ?? (role.classified ? 'TIME' : null)
}));
this.reloadSelection(role);
}
this.rebuildGroups();
}
@@ -282,15 +314,22 @@ export class RbiReserveConfigComponent implements OnInit {
get screenSubtitle(): string {
return (
'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.'
'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.'
);
}
/** A role with nothing mapped makes its return refuse to compute — surfaced, not hidden. */
/**
* A role with nothing mapped makes its return refuse to compute — surfaced, not hidden.
*
* Read from what is STORED, not from the draft on screen. The returns run off the saved mapping,
* so a row cleared but not yet saved changes nothing for them; counting it as unmapped made the
* banner announce that returns could not compute while they were still computing perfectly well.
* The unsaved edit is already shown by Save turning active.
*/
isUnmapped(role: any): boolean {
return !(this.selection[role.configKey] || []).length;
return !(role.accounts || []).length;
}
get unmappedCount(): number {
@@ -327,11 +366,29 @@ export class RbiReserveConfigComponent implements OnInit {
// a bank look compliant when it is not.
this.selection[role.configKey] = [
...(this.selection[role.configKey] || []),
{ glAccountId: null as any, weightPercentage: 100, liabilityClass: role.classified ? 'TIME' : null }];
{
glAccountId: null as any,
weightPercentage: 100,
liabilityClass: role.classified ? 'TIME' : null,
rowId: this.nextRowId++
}];
}
/**
* Drops a row and saves the head straight away.
*
* The bin used to clear the row on screen and leave it to Save, which read as the mapping having
* been removed when nothing had reached the server - the row came back on the next refresh. A bin
* is understood to delete, so it deletes: a row with nothing chosen in it never existed on the
* server and only has to go from the screen, and anything else is written through at once.
*/
removeMapping(role: any, index: number): void {
this.selection[role.configKey] = (this.selection[role.configKey] || []).filter((_, i) => i !== index);
const rows = this.selection[role.configKey] || [];
const removed = rows[index];
this.selection[role.configKey] = rows.filter((_, i) => i !== index);
if (removed?.glAccountId) {
this.saveRole(role);
}
}
/**
@@ -343,7 +400,23 @@ export class RbiReserveConfigComponent implements OnInit {
const rows = this.selection[role.configKey] || [];
const taken = new Set(rows.filter((_, i) => i !== index).map((m) => `${m.glAccountId}/${m.liabilityClass ?? ''}`));
const current = rows[index];
return this.glAccountOptions.filter((o) => !taken.has(`${o.id}/${current?.liabilityClass ?? ''}`));
const free = this.glAccountOptions.filter((o) => !taken.has(`${o.id}/${current?.liabilityClass ?? ''}`));
// Typed into the search box above the list. Matched on code AND name, because an administrator
// looking for a head knows one or the other - the GL code off a trial balance, or the name off
// the balance sheet - and should not have to know which this list is sorted by.
const term = (this.accountSearch || '').trim().toLowerCase();
if (!term) {
return free;
}
// The chosen account stays in the list whatever is typed. Dropping it would leave mat-select
// with a value that matches no option, and the row would read as empty.
return free.filter(
(o) =>
o.id === current?.glAccountId ||
`${o.glCode}`.toLowerCase().includes(term) ||
`${o.name}`.toLowerCase().includes(term)
);
}
/** A row that cannot be saved - no account chosen, or a portion outside 0-100. */
@@ -379,6 +452,16 @@ export class RbiReserveConfigComponent implements OnInit {
.filter((e) => Math.abs(e.total - 100) > 0.0001);
}
/** Rebuilds one head's rows from what the server last returned, discarding the draft. */
private reloadSelection(role: any): void {
this.selection[role.configKey] = (role.accounts || []).map((a: any) => ({
glAccountId: a.glAccountId,
weightPercentage: a.weightPercentage === null || a.weightPercentage === undefined ? 100 : +a.weightPercentage,
liabilityClass: a.liabilityClass ?? (role.classified ? 'TIME' : null),
rowId: this.nextRowId++
}));
}
saveRole(role: any): void {
this.savingKey = role.configKey;
this.errorMsg = '';
@@ -398,6 +481,11 @@ export class RbiReserveConfigComponent implements OnInit {
catchError((err: any) => {
this.savingKey = null;
this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this mapping.';
// Put the screen back to what the server holds. A failed save that left the draft showing
// would say the change had gone through while the server still had the old mapping - and
// the bin deletes immediately, so this is the only thing between a rejected delete and a
// row that looks gone but is not.
this.reloadSelection(role);
return of(null);
})
)
+1 -1
View File
@@ -62,7 +62,7 @@ export class AccountsFilterPipe implements PipeTransform {
account.status.code === 'savingsAccountStatusType.rejected'
);
});
} else {
} else if (status !== 'all') {
accounts = accounts.filter((account: any) => {
return (
account.status.code !== 'savingsAccountStatusType.withdrawn.by.applicant' &&
+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;
@@ -89,7 +89,18 @@
<p class="base-rate-remarks">{{ activeBaseVersion.remarks }}</p>
}
<table class="base-rate-table" mat-table [dataSource]="activeBaseVersion.slabs">
<p class="base-rate-remarks">
RD tenure is counted in months, from {{ rdChart.minTenureMonths ?? '-' }} months (the Minimum Tenure on the
Terms tab). A rate below the first slab comes from the RD CBS base chart; the rest from this chart.
</p>
@if (rdChart.shortTermRateMissing) {
<p class="base-rate-remarks">
The RD CBS base chart has no rate for {{ configuredMinTenureMonths }} months, so this product can only be
opened from {{ rdChart.minTenureMonths }} months.
</p>
}
<table class="base-rate-table" mat-table [dataSource]="activeBaseSlabs">
<ng-container matColumnDef="srNo">
<th mat-header-cell *matHeaderCellDef>Sr.</th>
<td mat-cell *matCellDef="let slab">{{ slab.srNo }}</td>
@@ -35,6 +35,8 @@ import { ProductsService } from 'app/products/products.service';
import { SettingsService } from 'app/settings/settings.service';
import { Accounting } from 'app/core/utils/accounting';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
import { buildRdChart, RdChart, tenureInMonths } from '../rd-product-chart';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { RecurringDepositProductPreviewStepComponent } from '../recurring-deposit-product-stepper/recurring-deposit-product-preview-step/recurring-deposit-product-preview-step.component';
import { MatTabsModule } from '@angular/material/tabs';
@@ -74,6 +76,7 @@ export class CreateRecurringDepositProductComponent {
private settingsService = inject(SettingsService);
private accounting = inject(Accounting);
private dateUtils = inject(Dates);
private alertService = inject(AlertService);
@ViewChild(RecurringDepositProductDetailsStepComponent, { static: true })
recurringDepositProductDetailsStep: RecurringDepositProductDetailsStepComponent;
@@ -92,6 +95,9 @@ export class CreateRecurringDepositProductComponent {
accountingRuleData: string[] = [];
selectedTabIndex = 0;
rdInterestRates: any[] = [];
rdBaseSlabs: any[] = [];
private rdChartCache: { baseSlabs: any[]; rdBaseSlabs: any[]; minTenureMonths: number | null; chart: RdChart } | null =
null;
readonly rdSlabColumns = [
'srNo',
'description',
@@ -122,6 +128,17 @@ export class CreateRecurringDepositProductComponent {
this.rdInterestRates = [];
}
});
// The RD base chart supplies the rate for 6 months to below 1 year, which the FD chart does not cover
this.productsService.getRdBaseInterestRates().subscribe({
next: (rates: any) => {
const versions: any[] = Array.isArray(rates) ? rates : rates ? [rates] : [];
const active = versions.find((version: any) => version.isActive) ?? versions[0];
this.rdBaseSlabs = active?.slabs || [];
},
error: () => {
this.rdBaseSlabs = [];
}
});
}
get recurringDepositProductDetailsForm() {
@@ -167,11 +184,37 @@ export class CreateRecurringDepositProductComponent {
return null;
}
/** The product's chart in months, from 6 months up - see rd-product-chart.ts. */
/** The minimum tenure entered on the Terms tab, in months. */
get configuredMinTenureMonths(): number | null {
const terms = this.recurringDepositProductTermsForm?.value;
return tenureInMonths(terms?.minDepositTerm, terms?.minDepositTermTypeId);
}
get rdChart(): RdChart {
const baseSlabs = this.activeBaseVersion?.slabs || [];
const minTenureMonths = this.configuredMinTenureMonths;
if (
this.rdChartCache?.baseSlabs !== baseSlabs ||
this.rdChartCache?.rdBaseSlabs !== this.rdBaseSlabs ||
this.rdChartCache?.minTenureMonths !== minTenureMonths
) {
this.rdChartCache = {
baseSlabs,
rdBaseSlabs: this.rdBaseSlabs,
minTenureMonths,
chart: buildRdChart(baseSlabs, this.rdBaseSlabs, minTenureMonths)
};
}
return this.rdChartCache.chart;
}
get activeBaseSlabs() {
return this.activeBaseVersion?.slabs || [];
return this.rdChart.slabs;
}
get recurringDepositProduct() {
const rdChart = this.rdChart;
return {
...this.recurringDepositProductDetailsStep.recurringDepositProductDetails,
...this.recurringDepositProductCurrencyStep.recurringDepositProductCurrency,
@@ -179,14 +222,20 @@ export class CreateRecurringDepositProductComponent {
...this.recurringDepositProductSettingsStep.recurringDepositProductSettings,
...this.recurringDepositProductChargesStep.recurringDepositProductCharges,
...this.recurringDepositProductAccountingStep.recurringDepositProductAccounting,
// The minimum comes from the Terms tab. Only when the chart has no rate that short is it raised to where the
// chart's rates begin, so no account can be opened for a tenure the chart cannot price.
...(rdChart.minTenureMonths != null && rdChart.minTenureMonths !== this.configuredMinTenureMonths
? { minDepositTerm: rdChart.minTenureMonths, minDepositTermTypeId: 2 }
: {}),
charts: this.recurringDepositProductCharts,
rdSlabs: this.activeBaseSlabs
rdSlabs: rdChart.slabs
};
}
get recurringDepositProductCharts() {
const activeBaseVersion = this.activeBaseVersion;
if (!activeBaseVersion?.slabs?.length) {
const slabs = this.rdChart.slabs;
if (!activeBaseVersion || !slabs.length) {
return [];
}
const dateFormat = this.settingsService.dateFormat;
@@ -208,7 +257,7 @@ export class CreateRecurringDepositProductComponent {
isPrimaryGroupingByAmount: false,
locale: this.settingsService.language.code,
dateFormat,
chartSlabs: activeBaseVersion.slabs.map((slab: any) => {
chartSlabs: slabs.map((slab: any) => {
const chartSlab: any = {
periodType: slab.periodTypeEnum,
fromPeriod: slab.fromPeriod,
@@ -232,7 +281,28 @@ export class CreateRecurringDepositProductComponent {
] as any[];
}
/**
* Terms (min/max installment) and Settings (lien percentage) have rules of their own that the Next buttons do not
* enforce. Opens the first tab that breaks one, with its errors shown, instead of submitting.
*/
private focusFirstInvalidStep(): boolean {
const steps = [
{ form: this.recurringDepositProductTermsForm, tab: 2 },
{ form: this.recurringDepositProductSettingsForm, tab: 3 }
];
const invalid = steps.find((step) => step.form?.invalid);
if (!invalid) {
return false;
}
invalid.form.markAllAsTouched();
this.selectedTabIndex = invalid.tab;
return true;
}
submit() {
if (this.focusFirstInvalidStep()) {
return;
}
const recurringDepositProduct: any = this.pruneEmptyValues({
...this.recurringDepositProduct,
charts: this.recurringDepositProductCharts,
@@ -243,14 +313,24 @@ export class CreateRecurringDepositProductComponent {
recurringDepositProduct.description = '';
}
delete recurringDepositProduct.advancedAccountingRules;
this.productsService.createRecurringDepositProduct(recurringDepositProduct).subscribe((response: any) => {
this.router.navigate(
[
'../',
response.resourceId
],
{ relativeTo: this.route }
);
this.productsService.createRecurringDepositProduct(recurringDepositProduct).subscribe({
next: (response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Recurring deposit product creation request submitted successfully for approval.'
});
this.router.navigate(['../'], { relativeTo: this.route });
} else {
this.router.navigate(
[
'../',
response.resourceId
],
{ relativeTo: this.route }
);
}
}
});
}
@@ -241,7 +241,28 @@ export class EditRecurringDepositProductComponent {
] as any[];
}
/**
* Terms (min/max installment) and Settings (lien percentage) have rules of their own that the Next buttons do not
* enforce. Opens the first tab that breaks one, with its errors shown, instead of submitting.
*/
private focusFirstInvalidStep(): boolean {
const steps = [
{ form: this.recurringDepositProductTermsForm, tab: 2 },
{ form: this.recurringDepositProductSettingsForm, tab: 3 }
];
const invalid = steps.find((step) => step.form?.invalid);
if (!invalid) {
return false;
}
invalid.form.markAllAsTouched();
this.selectedTabIndex = invalid.tab;
return true;
}
submit() {
if (this.focusFirstInvalidStep()) {
return;
}
const recurringDepositProduct: any = this.pruneEmptyValues({
...this.recurringDepositProduct,
charges: (this.recurringDepositProduct.charges ?? []).map((charge: any) => ({ id: charge.id })),
@@ -0,0 +1,207 @@
/**
* 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/.
*/
/**
* Builds a recurring deposit product's interest chart in months.
*
* RD products take their rates from the FD "DOUBLE" CBS base chart, which is kept in years and starts at 1 year, so
* on its own it leaves every tenure under 12 months without a rate. When the product's minimum tenure is shorter than
* that, the band from the minimum up to just below the first slab takes its rate from the RD CBS base chart - the slab
* covering the shortest possible span of that many months.
*
* Every slab is expressed in months because the backend matches a tenure to a slab by counting whole units of the
* slab's own period type: a 30-month RD is "2 years" against a year slab, so "1-2 Years" really covers 12-35 months.
* Writing that out as months keeps the chart identical in meaning while letting a single chart hold the sub-year band.
*/
/** SavingsPeriodFrequencyType ids. */
const MONTHS_PERIOD_TYPE = 2;
const YEARS_PERIOD_TYPE = 3;
export interface RdChartSlab {
srNo: number;
description: string;
periodTypeEnum: number;
periodTypeLabel: string;
fromPeriod: number;
toPeriod: number | null;
annualInterestRate: number;
seniorCitizenRate?: number;
amountRangeFrom?: number;
amountRangeTo?: number;
}
export interface RdChart {
slabs: RdChartSlab[];
/** Shortest tenure, in months, the product opens - its minimum deposit term. */
minTenureMonths: number | null;
/** Set when the RD base chart has no rate for the minimum tenure, so the product can only start at its first slab. */
shortTermRateMissing: boolean;
}
/** A tenure given as a count of months or years (SavingsPeriodFrequencyType id), in months. */
export function tenureInMonths(term: number | string | null | undefined, termTypeId: number | null | undefined): number | null {
const value = Number(term);
if (term === null || term === undefined || term === '' || !Number.isFinite(value) || value <= 0) {
return null;
}
return termTypeId === YEARS_PERIOD_TYPE ? value * 12 : value;
}
function unitInMonths(slab: any): number | null {
const label = String(slab.periodTypeLabel ?? slab.periodType?.value ?? '').toLowerCase();
if (label.startsWith('year')) {
return 12;
}
if (label.startsWith('month')) {
return 1;
}
return null;
}
function inDays(slab: any, period: number): number | null {
const label = String(slab.periodTypeLabel ?? slab.periodType?.value ?? '').toLowerCase();
if (label.startsWith('day')) {
return period;
}
if (label.startsWith('week')) {
return period * 7;
}
if (label.startsWith('month')) {
return period * 30;
}
if (label.startsWith('year')) {
return period * 365;
}
return null;
}
/** The fewest calendar days any run of {@code months} months can span (6 months -> 181, January to July). */
function shortestSpanInDays(months: number): number {
let shortest = Infinity;
for (let start = 0; start < 12; start++) {
const from = Date.UTC(2025, start, 1);
const to = Date.UTC(2025, start + months, 1);
shortest = Math.min(shortest, Math.round((to - from) / 86400000));
}
return shortest;
}
/** The RD base chart rate for an RD of {@code months} months, or null when that chart has no slab covering it. */
function shortTermRate(rdBaseSlabs: any[], months: number): number | null {
const days = shortestSpanInDays(months);
const slab = (rdBaseSlabs || []).find((candidate: any) => {
const from = inDays(candidate, candidate.fromPeriod ?? 0);
const to = candidate.toPeriod == null ? Infinity : inDays(candidate, candidate.toPeriod);
return from != null && to != null && from <= days && days <= to;
});
return slab?.annualInterestRate ?? null;
}
/**
* Month-wise slabs from {@code fromMonths} to {@code toMonths}, each month priced from the RD base chart and runs of
* months at the same rate merged into one slab - so a 3-month minimum against a chart with day bands for 46-90,
* 91-180 and 181-364 days becomes 3, 4-5 and 6-11 months rather than one rate for the lot. Empty when the base chart
* has no rate for the first month; a later month without one carries the previous month's rate.
*/
function shortTermBands(rdBaseSlabs: any[], fromMonths: number, toMonths: number): RdChartSlab[] {
const bands: RdChartSlab[] = [];
let previousRate: number | null = null;
let bandStart = fromMonths;
for (let month = fromMonths; month <= toMonths; month++) {
const rate = shortTermRate(rdBaseSlabs, month) ?? previousRate;
if (rate == null) {
return [];
}
const last = bands[bands.length - 1];
if (last && last.annualInterestRate === rate) {
last.toPeriod = month;
last.description = monthsLabel(bandStart, month);
} else {
bandStart = month;
bands.push({
srNo: 0,
description: monthsLabel(month, month),
periodTypeEnum: MONTHS_PERIOD_TYPE,
periodTypeLabel: 'Months',
fromPeriod: month,
toPeriod: month,
annualInterestRate: rate
});
}
previousRate = rate;
}
return bands;
}
function monthsLabel(from: number, to: number | null): string {
return to == null ? `${from}+ Months` : `${from} to ${to} Months`;
}
/**
* @param minTenureMonths the product's minimum tenure in months; null when not yet entered, in which case the chart
* simply starts at its first slab
*/
export function buildRdChart(baseSlabs: any[], rdBaseSlabs: any[], minTenureMonths: number | null): RdChart {
const slabs: RdChartSlab[] = [];
const sorted = [...(baseSlabs || [])].sort((a: any, b: any) => (a.fromPeriod ?? 0) - (b.fromPeriod ?? 0));
for (const slab of sorted) {
const unit = unitInMonths(slab);
if (unit == null) {
continue;
}
const from = (slab.fromPeriod ?? 0) * unit;
const to = slab.toPeriod == null ? null : (slab.toPeriod + 1) * unit - 1;
const converted: RdChartSlab = {
srNo: 0,
description: monthsLabel(from, to),
periodTypeEnum: MONTHS_PERIOD_TYPE,
periodTypeLabel: 'Months',
fromPeriod: from,
toPeriod: to,
annualInterestRate: slab.annualInterestRate
};
if (slab.seniorCitizenRate != null) {
converted.seniorCitizenRate = slab.seniorCitizenRate;
}
if (slab.amountRangeFrom != null && slab.amountRangeFrom !== '') {
converted.amountRangeFrom = slab.amountRangeFrom;
}
if (slab.amountRangeTo != null && slab.amountRangeTo !== '') {
converted.amountRangeTo = slab.amountRangeTo;
}
slabs.push(converted);
}
// The server only accepts a chart whose first slab starts at 0 or 1, so the chart always opens at 1 month and the
// product's minimum deposit term - not the chart - is what keeps an RD from being opened below the minimum.
let shortTermRateMissing = false;
let effectiveMinimum: number | null = null;
const firstFrom = slabs.length ? slabs[0].fromPeriod : null;
if (firstFrom != null) {
effectiveMinimum = minTenureMonths == null ? firstFrom : Math.max(minTenureMonths, 1);
if (firstFrom > 1) {
const needsShortTermBand = minTenureMonths != null && minTenureMonths < firstFrom;
const shortTermSlabs = needsShortTermBand ? shortTermBands(rdBaseSlabs, minTenureMonths, firstFrom - 1) : [];
if (shortTermSlabs.length) {
// The first band is opened down to 1 month for the server; its label still names the real minimum.
shortTermSlabs[0].fromPeriod = 1;
slabs.unshift(...shortTermSlabs);
} else {
// No rate below the first slab: stretch it down to 1 month to satisfy the server, but keep the minimum at
// where that slab really starts so nothing shorter can be opened at its rate.
shortTermRateMissing = needsShortTermBand;
slabs[0].fromPeriod = 1;
effectiveMinimum = Math.max(effectiveMinimum, firstFrom);
}
}
}
slabs.forEach((slab, index) => (slab.srNo = index + 1));
return { slabs, minTenureMonths: effectiveMinimum, shortTermRateMissing };
}
@@ -95,11 +95,14 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
otherGlMappingsMap: this.recurringDepositProductsTemplate?.otherGlMappingsMap
};
const accountingMappingOptions = this.recurringDepositProductsTemplate?.accountingMappingOptions || {};
// Defaults to Cash (2) for a brand-new product (no existing accountingRule yet), but preserves
// the product's own current accounting rule when editing - previously this was hardcoded to 2
// unconditionally, which silently forced every edited product onto Cash-based accounting (and
// its required GL mappings) even for products that were and should remain "None".
const accountingRuleId = this.recurringDepositProductsTemplate?.accountingRule?.id ?? 2;
// Cash (2) for a brand-new product, but the product's own current accounting rule when editing -
// previously this was hardcoded to 2 unconditionally, which silently forced every edited product
// onto Cash-based accounting (and its required GL mappings) even for products that were and
// should remain "None". Only an existing product (one with an id) has a rule of its own: the
// create template always reports accountingRule = None, so reading it there submitted every new
// product as None while this screen showed "Cash", and hid the GL mappings it needs.
const isEditing = !!this.recurringDepositProductsTemplate?.id;
const accountingRuleId = isEditing ? (this.recurringDepositProductsTemplate?.accountingRule?.id ?? 2) : 2;
this.assetAccountOptions = accountingMappingOptions.assetAccountOptions || [];
this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || [];
@@ -172,11 +175,20 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
if (this.isCashOrAccrualAccounting()) {
const glMappings = mappings.otherGlMappingsMap || mappings.otherGlMappings;
this.setOtherGlMappings('assetsOtherGlMappings', glMappings?.assets || [], this.assetAccountOptions);
this.setOtherGlMappings(
'liabilitiesOtherGlMappings',
glMappings?.liabilities || [],
// Interest Paid is a liability clearing account (Dr Payable / Cr Paid, then Dr Paid / Cr RD), saved under
// liabilities. Products saved before that kept it under expenses, so both groups are read.
const fromLiabilities = this.extractNamedGlMapping(
this.extractNamedGlMapping(
glMappings?.liabilities || [],
RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
'savingsInterestPaidAccountId',
this.liabilityAccountOptions
),
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
'savingsInterestPaidAccountId',
this.liabilityAccountOptions
);
this.setOtherGlMappings('liabilitiesOtherGlMappings', fromLiabilities, this.liabilityAccountOptions);
const fromExpenses = this.extractNamedGlMapping(
this.extractNamedGlMapping(
glMappings?.expenses || [],
@@ -265,15 +277,15 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
const otherGlMappings = {
assets: map('assetsOtherGlMappings', this.assetAccountOptions),
liabilities: map('liabilitiesOtherGlMappings', this.liabilityAccountOptions),
expenses: [
liabilities: [
...namedAsOtherGlMapping(
'savingsInterestPaidAccountId',
RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
this.expenseAccountOptions
this.liabilityAccountOptions
),
...map('expensesOtherGlMappings', this.expenseAccountOptions)
...map('liabilitiesOtherGlMappings', this.liabilityAccountOptions)
],
expenses: map('expensesOtherGlMappings', this.expenseAccountOptions),
income: map('incomeOtherGlMappings', this.incomeAccountOptions)
};
return Object.values(otherGlMappings).some((v) => v.length) ? otherGlMappings : null;
@@ -312,6 +324,7 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
setRequired('incomeFromPenaltyAccountId', cashOrAccrual);
setRequired('feesReceivableAccountId', accrualOnly);
setRequired('penaltiesReceivableAccountId', accrualOnly);
// Required on accrual (the accrual job credits it); optional on cash, where it only routes the closure posting.
setRequired('interestPayableAccountId', accrualOnly);
setRequired('escheatLiabilityId', cashOrAccrual && this.isDormancyTrackingEnabled());
@@ -330,8 +343,8 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
rows: [
{
key: 'savingsReferenceAccountId',
label: 'Savings Reference Account',
inputLabel: 'Savings reference account',
label: 'Recurring Deposit Reference Account',
inputLabel: 'Recurring deposit reference account',
controlName: 'savingsReferenceAccountId',
options: this.assetAccountOptions,
required: true
@@ -364,8 +377,8 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
rows: [
{
key: 'savingsControlAccountId',
label: 'Savings Control Account',
inputLabel: 'Savings control account',
label: 'Recurring Deposit Control Account',
inputLabel: 'Recurring deposit control account',
controlName: 'savingsControlAccountId',
options: this.liabilityAccountOptions,
required: true
@@ -378,13 +391,22 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
options: this.liabilityAccountOptions,
required: true
},
// Offered on cash as well as accrual: the interest credited at maturity or premature closure is routed
// Dr Interest On Recurring Deposits / Cr Payable, Dr Payable / Cr Interest Paid, Dr Interest Paid / Cr
// the RD. Optional - a product naming neither posts straight to the control account as before.
{
key: 'interestPayableAccountId',
label: 'Interest Payable Account',
inputLabel: 'Interest payable account',
controlName: 'interestPayableAccountId',
options: this.liabilityAccountOptions,
accrualOnly: true
options: this.liabilityAccountOptions
},
{
key: 'savingsInterestPaidAccountId',
label: 'Recurring Deposit Interest Paid',
inputLabel: 'Recurring deposit interest paid',
controlName: 'savingsInterestPaidAccountId',
options: this.liabilityAccountOptions
},
{
key: 'escheatLiabilityId',
@@ -411,13 +433,6 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
controlName: 'interestOnSavingsAccountId',
options: this.expenseAccountOptions,
required: true
},
{
key: 'savingsInterestPaidAccountId',
label: 'Recurring Deposit Interest Paid',
inputLabel: 'Recurring deposit interest paid',
controlName: 'savingsInterestPaidAccountId',
options: this.expenseAccountOptions
}
],
otherMappingsControlName: 'expensesOtherGlMappings',
@@ -66,7 +66,10 @@
<div class="rdpc-alert">
<div class="rdpc-alert__content">
<h4>Nomination Facility</h4>
<p>Allow account holders to designate individuals to receive funds in the event of account holder's death.</p>
<p>
Yes: every RD under this product must have at least one nominee (100% share) before it can be opened. No:
nominees cannot be recorded on its RDs.
</p>
</div>
<mat-radio-group class="rdpc-alert__choices" formControlName="nominationFacility">
<mat-radio-button [value]="true">yes</mat-radio-button>
@@ -77,7 +80,11 @@
<div class="rdpc-alert">
<div class="rdpc-alert__content">
<h4>Auto Debit Facility</h4>
<p>Enable automatic deduction of funds for recurring payments, utility bills, or EMI obligations.</p>
<p>
Yes: an RD can opt in to having each installment collected automatically on its due date from its linked
Savings account (needs Deposit Account Link). If the balance is short, the installment stays overdue and is
retried on the next run.
</p>
</div>
<mat-radio-group class="rdpc-alert__choices" formControlName="autoDebitFacility">
<mat-radio-button [value]="true">yes</mat-radio-button>
@@ -68,37 +68,41 @@
</div>
<div class="rdpd-grid rdpd-grid--classification">
<mat-form-field appearance="outline" class="rdpd-field">
<mat-form-field appearance="outline" class="rdpd-field" subscriptSizing="dynamic">
<mat-label>Account Type</mat-label>
<mat-select formControlName="accountType" required>
@for (accountType of accountTypeData; track accountType) {
<mat-option [value]="accountType">{{ accountType }}</mat-option>
}
</mat-select>
<mat-hint>Default account type pre-selected when an RD is opened under this product.</mat-hint>
<mat-error>Account Type <strong>is required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="rdpd-field">
<mat-form-field appearance="outline" class="rdpd-field" subscriptSizing="dynamic">
<mat-label>GL Account (Liability)</mat-label>
<mat-select formControlName="glAccountId" [compareWith]="compareById">
@for (account of liabilityAccountOptions; track account.id) {
<mat-option [value]="account.id">{{ account.name }} ({{ account.glCode }})</mat-option>
}
</mat-select>
<mat-hint>Its GL code forms part of every new RD account number. Postings use the Accounting tab.</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="rdpd-field" (click)="effectiveToPicker.open()">
<mat-form-field appearance="outline" class="rdpd-field" subscriptSizing="dynamic" (click)="effectiveToPicker.open()">
<mat-label>Effective To</mat-label>
<input matInput [matDatepicker]="effectiveToPicker" formControlName="effectiveTo" />
<mat-datepicker-toggle matSuffix [for]="effectiveToPicker"></mat-datepicker-toggle>
<mat-datepicker #effectiveToPicker></mat-datepicker>
<mat-hint>Last day new RDs can be opened. Existing RDs run to maturity.</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="rdpd-field" (click)="endDatePicker.open()">
<mat-form-field appearance="outline" class="rdpd-field" subscriptSizing="dynamic" (click)="endDatePicker.open()">
<mat-label>End Date</mat-label>
<input matInput [matDatepicker]="endDatePicker" formControlName="endDate" [min]="minEndDate" />
<mat-datepicker-toggle matSuffix [for]="endDatePicker"></mat-datepicker-toggle>
<mat-datepicker #endDatePicker></mat-datepicker>
<mat-hint>Product wind-up date. No RD may mature after it.</mat-hint>
@if (recurringDepositProductDetailsForm.get('endDate')?.hasError('matDatepickerMin')) {
<mat-error>End Date must be after Effective To date</mat-error>
}
@@ -115,6 +119,10 @@
<div class="rdpd-field rdpd-field--full rdpd-checkbox-row">
<mat-checkbox formControlName="shareLinkingEnabled">Deposit Account Link</mat-checkbox>
<span class="rdpd-checkbox-hint">
Every RD must be linked to the customer's Savings/Current account - the default account for maturity payout
and the account Auto Debit collects installments from.
</span>
</div>
</div>
</div>
@@ -76,6 +76,13 @@ $gray-900: #202124;
.rdpd-checkbox-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 12px;
min-height: 56px;
}
.rdpd-checkbox-hint {
font-size: 12px;
opacity: 0.7;
}
@@ -150,7 +150,7 @@
<mat-divider class="flex-100"></mat-divider>
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Nominal Annual Interest Rate' | translate }} (%):</span>
<span class="flex-40">Post-Maturity Interest Rate (%):</span>
<span class="flex-60">{{ recurringDepositProduct.nominalAnnualInterestRate | formatNumber }} %</span>
</div>
@@ -199,41 +199,11 @@
@if (recurringDepositProduct.effectiveDate) {
<div class="flex-100 layout-row">
<span class="flex-40">Effective Date:</span>
<span class="flex-40">Product Effective From:</span>
<span class="flex-60">{{ recurringDepositProduct.effectiveDate | dateFormat }}</span>
</div>
}
@if (recurringDepositProduct.averagePeriod) {
<div class="flex-100 layout-row">
<span class="flex-40">Average Period:</span>
<span class="flex-60">{{ recurringDepositProduct.averagePeriod }}</span>
</div>
}
<div class="flex-100 layout-row">
<span class="flex-40">Auto Interest Post:</span>
<span class="flex-60">{{ recurringDepositProduct.autoPost | yesNo }}</span>
</div>
@if (
recurringDepositProduct.frequencyFixDay !== undefined &&
recurringDepositProduct.frequencyFixDay !== null &&
recurringDepositProduct.frequencyFixDay !== ''
) {
<div class="flex-100 layout-row">
<span class="flex-40">Frequency (Fix Day):</span>
<span class="flex-60">{{ recurringDepositProduct.frequencyFixDay }}</span>
</div>
}
@if (recurringDepositProduct.lastInterestDate) {
<div class="flex-100 layout-row">
<span class="flex-40">Last Interest Date:</span>
<span class="flex-60">{{ recurringDepositProduct.lastInterestDate | dateFormat }}</span>
</div>
}
<!-- ── Settings ── -->
<h3 class="mat-h3 flex-100">{{ 'labels.heading.Settings' | translate }}</h3>
<mat-divider class="flex-100"></mat-divider>
@@ -258,10 +228,13 @@
<span class="flex-60">{{ recurringDepositProduct.lienMarked | yesNo }}</span>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }}:</span>
<span class="flex-60">{{ recurringDepositProduct.withdrawalFeeForTransfers | yesNo }}</span>
</div>
@if (recurringDepositProduct.lienMarked && recurringDepositProduct.lienPercentage) {
<div class="flex-100 layout-row">
<span class="flex-40">Max loan against RD:</span>
<span class="flex-60">{{ recurringDepositProduct.lienPercentage }}% of balance</span>
</div>
}
<div class="flex-100 layout-row">
<span class="flex-40">Enable Lock-in Period:</span>
@@ -279,23 +252,24 @@
}
<div class="flex-100 layout-row">
<span class="flex-40">Maturity Instructions:</span>
<span class="flex-40">Execute Maturity Instruction Automatically:</span>
<span class="flex-60">{{ recurringDepositProduct.maturityInstructions | yesNo }}</span>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Credit Maturity Proceeds:</span>
<span class="flex-60">{{ recurringDepositProduct.creditMaturityProceeds | yesNo }}</span>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Penalty Grace Period (Missed Installments):</span>
<span class="flex-60">{{ recurringDepositProduct.penaltyGracePeriodMissedInstallments | yesNo }}</span>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Panel Request Applicable:</span>
<span class="flex-60">{{ recurringDepositProduct.panelRequestApplicable | yesNo }}</span>
<span class="flex-40">Missed Installment Penalty:</span>
<span class="flex-60">
@if (recurringDepositProduct.penaltyGracePeriodMissedInstallments) {
{{
recurringDepositProduct.missedInstallmentPenaltyType === 2
? recurringDepositProduct.missedInstallmentPenaltyAmount + '% of installment'
: recurringDepositProduct.missedInstallmentPenaltyAmount + ' per installment'
}}
after {{ recurringDepositProduct.missedInstallmentGraceDays ?? 0 }} grace day(s)
} @else {
No
}
</span>
</div>
<div class="flex-100 layout-row">
@@ -347,7 +321,7 @@
</ng-container>
<ng-container matColumnDef="toPeriod">
<th mat-header-cell *matHeaderCellDef>To</th>
<td mat-cell *matCellDef="let slab">{{ slab.toPeriod ?? '—' }}</td>
<td mat-cell *matCellDef="let slab">{{ slab.toPeriod ?? '—' }}</td>
</ng-container>
<ng-container matColumnDef="periodTypeLabel">
<th mat-header-cell *matHeaderCellDef>Period</th>
@@ -406,7 +380,7 @@
<div class="spa-gl-section">
<h5 class="mat-h5">Assets</h5>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Savings Reference Account</span>
<span class="spa-gl-row__label">Recurring Deposit Reference Account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
recurringDepositProduct.savingsReferenceAccountId,
@@ -439,7 +413,7 @@
<div class="spa-gl-section">
<h5 class="mat-h5">Liabilities</h5>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Savings Control Account</span>
<span class="spa-gl-row__label">Recurring Deposit Control Account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
recurringDepositProduct.savingsControlAccountId,
@@ -472,7 +446,7 @@
<div class="spa-gl-section">
<h5 class="mat-h5">Expenses</h5>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Interest on Savings Account</span>
<span class="spa-gl-row__label">Interest on Recurring Deposits</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
recurringDepositProduct.interestOnSavingsAccountId,
@@ -18,10 +18,34 @@
<mat-checkbox formControlName="loanFacility">Loan Facility</mat-checkbox>
<mat-checkbox formControlName="prematureWithdrawalOption">Premature Withdrawal option</mat-checkbox>
<mat-checkbox formControlName="tenureFlexibility">Tenure Flexibility</mat-checkbox>
<mat-checkbox formControlName="lienMarked">Lien Marked</mat-checkbox>
<mat-checkbox formControlName="withdrawalFeeForTransfers">Apply Withdrawal Fee for Transfers</mat-checkbox>
<mat-checkbox
formControlName="lienMarked"
title="RDs of this product can be pledged as loan security (guarantor account). A pledged RD cannot be closed until the loan is repaid."
>Lien Marked (can be pledged for a loan)</mat-checkbox
>
</div>
@if (recurringDepositProductSettingsForm.value.lienMarked) {
<div class="rdps-lockin-fields">
<mat-form-field appearance="outline" class="rdps-field" subscriptSizing="dynamic">
<mat-label>Max loan against RD (% of balance)</mat-label>
<input type="number" matInput formControlName="lienPercentage" min="1" max="100" step="0.01" required />
<mat-hint>
e.g. 90 = loan up to 90% of the RD balance. Lien is checked when the RD is added as a loan guarantor.
</mat-hint>
@if (recurringDepositProductSettingsForm.get('lienPercentage').hasError('required')) {
<mat-error>Enter the percentage of the RD balance that can be given as loan</mat-error>
}
@if (
recurringDepositProductSettingsForm.get('lienPercentage').hasError('min') ||
recurringDepositProductSettingsForm.get('lienPercentage').hasError('max')
) {
<mat-error>Must be between 1 and 100</mat-error>
}
</mat-form-field>
</div>
}
<mat-checkbox class="rdps-preclosure-toggle" formControlName="preClosurePenalApplicable">
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}
</mat-checkbox>
@@ -93,34 +117,85 @@
</div>
<div class="rdps-body rdps-body--list">
<div class="rdps-list-row">
<span>Maturity Instructions</span>
<span>
Execute maturity instruction automatically
<small class="rdps-row-hint">
On maturity day the instruction chosen when the RD was opened runs by itself - Transfer to Savings (to
the chosen or linked savings account) or Reinvest. Off: the RD stays Matured, earning the post-maturity
rate, until staff close it. Never runs while the RD has a lien.
</small>
</span>
<mat-checkbox formControlName="maturityInstructions"></mat-checkbox>
</div>
<div class="rdps-list-row">
<span
>Option to credit maturity proceeds directly to the linked account or reinvest in another product (like
FD).</span
>
<mat-checkbox formControlName="creditMaturityProceeds"></mat-checkbox>
</div>
<div class="rdps-list-row">
<span>Penalty / Grace Period for Missed Installments</span>
<span>
Penalty / Grace Period for Missed Installments
<small class="rdps-row-hint">
An installment still unpaid after the grace days is charged a penalty once, collected from the RD
balance (Income From Penalties).
</small>
</span>
<mat-checkbox formControlName="penaltyGracePeriodMissedInstallments"></mat-checkbox>
</div>
@if (recurringDepositProductSettingsForm.value.penaltyGracePeriodMissedInstallments) {
<div class="rdps-lockin-fields rdps-penalty-fields">
<mat-form-field appearance="outline" class="rdps-field" subscriptSizing="dynamic">
<mat-label>Grace days</mat-label>
<input type="number" matInput formControlName="missedInstallmentGraceDays" min="0" step="1" required />
<mat-hint>Due 10th + 5 grace days = penalty from the 16th</mat-hint>
@if (recurringDepositProductSettingsForm.get('missedInstallmentGraceDays').invalid) {
<mat-error>Whole days, 0 or more</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="rdps-field" subscriptSizing="dynamic">
<mat-label>Penalty type</mat-label>
<mat-select formControlName="missedInstallmentPenaltyType" required>
<mat-option [value]="1">Flat amount per installment</mat-option>
<mat-option [value]="2">% of the installment</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="rdps-field" subscriptSizing="dynamic">
<mat-label>{{
recurringDepositProductSettingsForm.value.missedInstallmentPenaltyType === 2 ? 'Penalty (%)' : 'Penalty amount'
}}</mat-label>
<input type="number" matInput formControlName="missedInstallmentPenaltyAmount" min="0.01" step="0.01" required />
@if (recurringDepositProductSettingsForm.get('missedInstallmentPenaltyAmount').invalid) {
<mat-error>{{
recurringDepositProductSettingsForm.value.missedInstallmentPenaltyType === 2
? 'Between 0.01 and 100'
: 'Must be more than 0'
}}</mat-error>
}
</mat-form-field>
</div>
}
<div class="rdps-list-row">
<span>Panel Request Applicable</span>
<mat-checkbox formControlName="panelRequestApplicable"></mat-checkbox>
</div>
<div class="rdps-list-row">
<span>No penalty for nominee / legal heir closure</span>
<span>
No penalty for nominee / legal heir closure
<small class="rdps-row-hint">
Premature close with reason "Nominee / legal heir claim" pays the full slab rate - no pre-closure penalty.
</small>
</span>
<mat-checkbox formControlName="noPenaltyNomineeHeirClosure"></mat-checkbox>
</div>
<div class="rdps-list-row">
<span>Deceased Settlement</span>
<span>
Deceased Settlement
<small class="rdps-row-hint">
Premature close with reason "Account holder deceased": no penalty, allowed even inside lock-in, and the
proceeds may go to a nominee's savings account.
</small>
</span>
<mat-checkbox formControlName="deceasedSettlement"></mat-checkbox>
</div>
<div class="rdps-list-row">
<span>Allow Debit Txn after due date</span>
<span>
Allow Debit Txn after due date
<small class="rdps-row-hint">
For RDs on Auto Debit. On: a missed installment keeps being collected on later days. Off: it is debited
only on its due date; a missed one stays overdue.
</small>
</span>
<mat-checkbox formControlName="allowDebitTxnAfterDueDate"></mat-checkbox>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More