Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 00:07:18 +05:30
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
47 changed files with 11005 additions and 2897 deletions
+7
View File
@@ -31,6 +31,8 @@
"@angular/localize/init"
],
"assets": [
"src/favicon.ico",
"src/apple-touch-icon.png",
"src/assets/images/cbsnexora-favicon.svg",
"src/robots.txt",
"src/manifest.json",
@@ -39,6 +41,11 @@
"glob": "**/*",
"input": "node_modules/tinymce",
"output": "assets/tinymce"
},
{
"glob": "lottie.min.js",
"input": "node_modules/lottie-web/build/player",
"output": "assets/js"
}
],
"styles": [
+7
View File
@@ -41,6 +41,7 @@
"jspdf-autotable": "^5.0.2",
"lightgallery": "^2.9.0",
"lodash": "4.17.23",
"lottie-web": "^5.13.0",
"moment": "^2.29.4",
"ngx-image-cropper": "^9.1.6",
"ngx-mat-select-search": "^8.0.2",
@@ -18380,6 +18381,12 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/lottie-web": {
"version": "5.13.0",
"resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz",
"integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+1
View File
@@ -71,6 +71,7 @@
"jspdf-autotable": "^5.0.2",
"lightgallery": "^2.9.0",
"lodash": "4.17.23",
"lottie-web": "^5.13.0",
"moment": "^2.29.4",
"ngx-image-cropper": "^9.1.6",
"ngx-mat-select-search": "^8.0.2",
+2
View File
@@ -96,6 +96,7 @@ import { TokenInterceptor as ZitadelTokenInterceptor } from './zitadel/token.int
import { AuthService } from './zitadel/auth.service';
import { environment } from '../environments/environment';
import { CallbackComponent } from './zitadel/callback/callback.component';
import { LoadingScreenComponent } from './shared/loading-screen/loading-screen.component';
import { OAuthModule } from 'angular-oauth2-oidc';
export class CustomMissingTranslationHandler implements MissingTranslationHandler {
@@ -195,6 +196,7 @@ export function HttpLoaderFactory(http: HttpClient) {
AppRoutingModule,
NotFoundComponent,
CallbackComponent,
LoadingScreenComponent,
OAuthModule.forRoot()
],
providers: [
@@ -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 {
+369 -275
View File
@@ -503,7 +503,8 @@
[matMenuTriggerFor]="loanOriginationMenu"
*mifosxHasPermission="'MENU_LOAN_ORIGINATION'"
>
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon><span class="ni-full">Loan Origination</span
><span class="ni-short">Loans</span>
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a
@@ -512,7 +513,8 @@
(click)="glMenuOpen = !glMenuOpen; subLedgerOpen = false"
*mifosxHasPermission="'MENU_GENERAL_LEDGER'"
>
<fa-icon icon="book-open" class="ni-ico"></fa-icon>General Ledger
<fa-icon icon="book-open" class="ni-ico"></fa-icon><span class="ni-full">General Ledger</span
><span class="ni-short">GL</span>
<fa-icon [icon]="glMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
<a
@@ -521,7 +523,8 @@
(click)="subLedgerOpen = !subLedgerOpen; glMenuOpen = false"
*mifosxHasPermission="'MENU_SUB_LEDGER'"
>
<fa-icon icon="book" class="ni-ico"></fa-icon>Sub Ledger
<fa-icon icon="book" class="ni-ico"></fa-icon><span class="ni-full">Sub Ledger</span
><span class="ni-short">Sub GL</span>
<fa-icon [icon]="subLedgerOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
</div>
@@ -583,14 +586,16 @@
[matMenuTriggerFor]="reconciliationMenu"
*mifosxHasPermission="'MENU_CONFIG_WIZARD'"
>
<fa-icon icon="sync-alt" class="ni-ico"></fa-icon>Reconciliation
<fa-icon icon="sync-alt" class="ni-ico"></fa-icon><span class="ni-full">Reconciliation</span
><span class="ni-short">Recon</span>
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" routerLink="/chequebook" *mifosxHasPermission="'MENU_CHEQUEBOOK'">
<fa-icon icon="book" class="ni-ico"></fa-icon>Cheque Book
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="letterMgmtMenu" *mifosxHasPermission="'MENU_LETTER_MGMT'">
<fa-icon icon="envelope" class="ni-ico"></fa-icon>Documents Hub
<fa-icon icon="envelope" class="ni-ico"></fa-icon><span class="ni-full">Documents Hub</span
><span class="ni-short">Docs Hub</span>
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
</div>
@@ -617,77 +622,89 @@
</button>
</mat-menu>
<mat-menu #reportsMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item [routerLink]="['/branch-ops/account-register']" tabindex="0">
<i class="fa fa-book menu-item-icon"></i> Account Register
</button>
<button mat-menu-item [routerLink]="['/reports']" tabindex="0">
<i class="fa fa-th-list menu-item-icon"></i> All Reports
</button>
<button mat-menu-item [routerLink]="['/reports', 'Client']" tabindex="0">
<i class="fa fa-user menu-item-icon"></i> Clients
</button>
<button mat-menu-item [routerLink]="['/reports', 'Loan']" tabindex="0">
<i class="fa fa-credit-card menu-item-icon"></i> Loans
</button>
<button mat-menu-item [routerLink]="['/reports', 'Savings']" tabindex="0">
<i class="fa fa-piggy-bank menu-item-icon"></i> Savings
</button>
<button mat-menu-item [routerLink]="['/reports', 'Fund']" tabindex="0">
<i class="fa fa-hand-holding-usd menu-item-icon"></i> Funds
</button>
<button mat-menu-item [routerLink]="['/reports', 'Accounting']" tabindex="0">
<i class="fa fa-calculator menu-item-icon"></i> Accounting
</button>
<mat-divider></mat-divider>
<!-- Listed here as well as under Branch Ops: an officer looking for the day's figures reaches
for Reports, and the same screen answers both. -->
<button mat-menu-item routerLink="/branch-ops/day-book" tabindex="0">
<i class="fa fa-book menu-item-icon"></i> Day Book &amp; Officer Verification
</button>
<button mat-menu-item routerLink="/branch-ops/end-of-day" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> End of the Day Report
</button>
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
<i class="fa fa-file-invoice menu-item-icon"></i> Form 97/98 Transaction Report
</button>
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
</button>
<button mat-menu-item routerLink="/reports/kyc-expiry" tabindex="0">
<i class="fa fa-id-card menu-item-icon"></i> Re-KYC Expiry &amp; Outreach
</button>
<button mat-menu-item routerLink="/reports/dormancy" tabindex="0">
<i class="fa fa-hourglass-half menu-item-icon"></i> Inactive, Dormant &amp; DEAF
</button>
<button mat-menu-item routerLink="/reports/savings-interest" tabindex="0">
<i class="fa fa-percentage menu-item-icon"></i> Savings Interest Posted
</button>
<button mat-menu-item routerLink="/reports/interest-posting" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> Interest Posting Reports
</button>
<button mat-menu-item routerLink="/reports/interest-posting-summary" tabindex="0">
<i class="fa fa-table menu-item-icon"></i> Interest Posting Summary (Branch-wise)
</button>
<!--
The Principal Officer clears flagged UNSC screenings here. It is not a runnable report,
so it never shows up under the report categories above and had no nav entry at all.
-->
<button mat-menu-item routerLink="/reports/sanctions-logs" tabindex="0">
<i class="fa fa-shield-alt menu-item-icon"></i> Sanctions Screening &amp; Alerts
</button>
<button mat-menu-item routerLink="/reports/dip-lookups" tabindex="0">
<i class="fa fa-satellite-dish menu-item-icon"></i> DIP / MNRL Mobile Lookups
</button>
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
<i class="fa fa-percent menu-item-icon"></i> Deposit Interest &amp; TDS
</button>
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
<i class="fa fa-landmark menu-item-icon"></i> TDS Remitted to Government
</button>
<button mat-menu-item routerLink="/reports/bor" tabindex="0">
<i class="fa fa-gavel menu-item-icon"></i> BOR Register &mdash; Share Capital
</button>
<mat-menu #reportsMenu="matMenu" class="reports-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid reports-grid" (click)="$event.stopPropagation()">
<!-- Core Reports -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Core Reports</div>
<button mat-menu-item [routerLink]="['/branch-ops/account-register']" tabindex="0">
<i class="fa fa-book menu-item-icon"></i> Account Register
</button>
<button mat-menu-item [routerLink]="['/reports']" tabindex="0">
<i class="fa fa-th-list menu-item-icon"></i> All Reports
</button>
<button mat-menu-item [routerLink]="['/reports', 'Client']" tabindex="0">
<i class="fa fa-user menu-item-icon"></i> Clients
</button>
<button mat-menu-item [routerLink]="['/reports', 'Loan']" tabindex="0">
<i class="fa fa-credit-card menu-item-icon"></i> Loans
</button>
<button mat-menu-item [routerLink]="['/reports', 'Savings']" tabindex="0">
<i class="fa fa-piggy-bank menu-item-icon"></i> Savings
</button>
<button mat-menu-item [routerLink]="['/reports', 'Fund']" tabindex="0">
<i class="fa fa-hand-holding-usd menu-item-icon"></i> Funds
</button>
<button mat-menu-item [routerLink]="['/reports', 'Accounting']" tabindex="0">
<i class="fa fa-calculator menu-item-icon"></i> Accounting
</button>
</div>
<!-- Operations & Interest -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Operations &amp; Interest</div>
<button mat-menu-item routerLink="/branch-ops/day-book" tabindex="0">
<i class="fa fa-book menu-item-icon"></i> Day Book &amp; Officer Verification
</button>
<button mat-menu-item routerLink="/branch-ops/end-of-day" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> End of the Day Report
</button>
<button mat-menu-item routerLink="/reports/savings-interest" tabindex="0">
<i class="fa fa-percentage menu-item-icon"></i> Savings Interest Posted
</button>
<button mat-menu-item routerLink="/reports/interest-posting" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> Interest Posting Reports
</button>
<button mat-menu-item routerLink="/reports/fd-interest-posting" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> FD Interest Posting Reports
</button>
<button mat-menu-item routerLink="/reports/interest-posting-summary" tabindex="0">
<i class="fa fa-table menu-item-icon"></i> Interest Posting Summary (Branch-wise)
</button>
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
<i class="fa fa-percent menu-item-icon"></i> Deposit Interest &amp; TDS
</button>
</div>
<!-- Compliance & Registers -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Compliance &amp; Registers</div>
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
<i class="fa fa-file-invoice menu-item-icon"></i> Form 97/98 Transaction Report
</button>
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
</button>
<button mat-menu-item routerLink="/reports/kyc-expiry" tabindex="0">
<i class="fa fa-id-card menu-item-icon"></i> Re-KYC Expiry &amp; Outreach
</button>
<button mat-menu-item routerLink="/reports/dormancy" tabindex="0">
<i class="fa fa-hourglass-half menu-item-icon"></i> Inactive, Dormant &amp; DEAF
</button>
<button mat-menu-item routerLink="/reports/sanctions-logs" tabindex="0">
<i class="fa fa-shield-alt menu-item-icon"></i> Sanctions Screening &amp; Alerts
</button>
<button mat-menu-item routerLink="/reports/dip-lookups" tabindex="0">
<i class="fa fa-satellite-dish menu-item-icon"></i> DIP / MNRL Mobile Lookups
</button>
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
<i class="fa fa-landmark menu-item-icon"></i> TDS Remitted to Government
</button>
<button mat-menu-item routerLink="/reports/bor" tabindex="0">
<i class="fa fa-gavel menu-item-icon"></i> BOR Register &mdash; Share Capital
</button>
</div>
</div>
</mat-menu>
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
@@ -1060,121 +1077,146 @@
<!-- Sub Menu Dropdowns -->
<!-- Branch-level operations. Starts with the day register; other branch-wide controls belong
here rather than under Cashier, which is one role's workspace. -->
<mat-menu #branchOpsMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/day-register']">
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> Day Open / Day Close
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/missed-close']">
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Missed Close Register
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/vault-position']">
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Vault Position
</button>
<!-- The day book carries the Officer Verification Sheet — each person's credit, debit and net
for the day. It was reachable only from the Clerk and Cashier menus, which is the one
place a principal officer signing the day off does not look. -->
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book &amp; Verification
</button>
<!-- The signed sheet, as against the day book's working list: the day's modes, the drawer
against the books, and what is still open. This is what goes in the file. -->
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/end-of-day']">
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> End of the Day Report
</button>
<mat-menu #branchOpsMenu="matMenu" class="branch-ops-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid branch-ops-grid" (click)="$event.stopPropagation()">
<div class="deposit-menu-group">
<div class="deposit-group-title">Day Operations</div>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/day-register']">
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> Day Open / Day Close
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/missed-close']">
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Missed Close Register
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/vault-position']">
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Vault Position
</button>
</div>
<div class="deposit-menu-group">
<div class="deposit-group-title">Verification &amp; Reports</div>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book &amp; Verification
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/end-of-day']">
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> End of the Day Report
</button>
</div>
</div>
</mat-menu>
<!-- Clerk was a bare link straight into the fullscreen dashboard, which left the clerk's other
screens reachable only from inside that fullscreen nav. Anything a clerk needs from the
normal shell — an open voucher exception above all — belongs here. -->
<mat-menu #clerkMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" routerLink="/clerk/dashboard" (click)="hideShellLayout = true">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Clerk Dashboard
</button>
<!-- The clerk reads the day as a sheet to sign off, not as a working list of vouchers: the
day's summary, the counter counted against the books, and what is still open. The day
book's own row-by-row view stays reachable from Branch Ops and from the Cashier menu. -->
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/end-of-day']">
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> End of the Day Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/withdrawal-requests']">
<fa-icon icon="money-bill-wave" class="menu-item-icon"></fa-icon> My Withdrawal Requests
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/my-disbursement-requests']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Disbursements &amp; Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/dividend-dashboard']">
<fa-icon icon="chart-pie" class="menu-item-icon"></fa-icon> Dividend Dashboard
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/my-work']">
<fa-icon icon="user-check" class="menu-item-icon"></fa-icon> My Vouchers
</button>
<!-- Sits under My Vouchers because it answers the same question for the one worker who has no
own list: the system. A nightly run belongs to nobody, so it appears on no person's page. -->
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/system-vouchers']">
<fa-icon icon="robot" class="menu-item-icon"></fa-icon> System Vouchers
</button>
<!-- A clerk supervises the counter, so they need to see what each cashier is holding and what
went through their drawer. Both screens already return every cashier for a non-cashier
caller (CashierRoleHelper scopes only a cashier to themselves) — they were simply not
reachable from this menu. -->
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cashier-position']">
<fa-icon icon="wallet" class="menu-item-icon"></fa-icon> Cashier Position
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/closing-report']">
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Closing Cash Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/received-report']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Received Vouchers
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/payment-report']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Payment Vouchers
</button>
<!-- Clerk Desk -->
<mat-menu #clerkMenu="matMenu" class="clerk-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid clerk-grid" (click)="$event.stopPropagation()">
<!-- Clerk Desk -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Clerk Desk</div>
<button mat-menu-item tabindex="0" routerLink="/clerk/dashboard" (click)="hideShellLayout = true">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Clerk Dashboard
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/withdrawal-requests']">
<fa-icon icon="money-bill-wave" class="menu-item-icon"></fa-icon> My Withdrawal Requests
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/my-disbursement-requests']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Disbursements &amp; Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/dividend-dashboard']">
<fa-icon icon="chart-pie" class="menu-item-icon"></fa-icon> Dividend Dashboard
</button>
</div>
<!-- Vouchers -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Vouchers</div>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/my-work']">
<fa-icon icon="user-check" class="menu-item-icon"></fa-icon> My Vouchers
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/system-vouchers']">
<fa-icon icon="robot" class="menu-item-icon"></fa-icon> System Vouchers
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/received-report']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Received Vouchers
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/payment-report']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Payment Vouchers
</button>
</div>
<!-- Counter & Reports -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Counter &amp; Reports</div>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cashier-position']">
<fa-icon icon="wallet" class="menu-item-icon"></fa-icon> Cashier Position
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/closing-report']">
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Closing Cash Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/end-of-day']">
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> End of the Day Report
</button>
</div>
</div>
</mat-menu>
<mat-menu #cashierMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-exchange']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Cash Exchange Entry
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/system/denomination-master']">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Cash Denomination
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/closing-report']">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Closing Cash Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/exchange-detail']">
<fa-icon icon="globe" class="menu-item-icon"></fa-icon> Currency Exchange Detail
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-transfer']">
<fa-icon icon="random" class="menu-item-icon"></fa-icon> Cash Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/excess-report']">
<fa-icon icon="exclamation-circle" class="menu-item-icon"></fa-icon> Excess Transaction Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-settlement']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Cash Settlement
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/inter-cashier']">
<fa-icon icon="exchange-alt" class="menu-item-icon"></fa-icon> Inter-Cashier Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cashier-position']">
<fa-icon icon="wallet" class="menu-item-icon"></fa-icon> Cashier Position
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/reconciliation']">
<fa-icon icon="balance-scale" class="menu-item-icon"></fa-icon> Reconciliation
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-history']">
<fa-icon icon="history" class="menu-item-icon"></fa-icon> Cash History
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']">
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
</button>
<!-- Cashier Desk -->
<mat-menu #cashierMenu="matMenu" class="cashier-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid cashier-grid" (click)="$event.stopPropagation()">
<!-- Cash Entries & Exchange -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Cash &amp; Counter</div>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-exchange']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Cash Exchange Entry
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/exchange-detail']">
<fa-icon icon="globe" class="menu-item-icon"></fa-icon> Currency Exchange Detail
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']">
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/system/denomination-master']">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Cash Denomination
</button>
</div>
<!-- Transfers & Settlement -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Transfers &amp; Settlement</div>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-transfer']">
<fa-icon icon="random" class="menu-item-icon"></fa-icon> Cash Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/inter-cashier']">
<fa-icon icon="exchange-alt" class="menu-item-icon"></fa-icon> Inter-Cashier Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-settlement']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Cash Settlement
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cashier-position']">
<fa-icon icon="wallet" class="menu-item-icon"></fa-icon> Cashier Position
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
</button>
</div>
<!-- Reports & Audit -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Reports &amp; Audit</div>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/closing-report']">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Closing Cash Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/excess-report']">
<fa-icon icon="exclamation-circle" class="menu-item-icon"></fa-icon> Excess Transaction Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/reconciliation']">
<fa-icon icon="balance-scale" class="menu-item-icon"></fa-icon> Reconciliation
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-history']">
<fa-icon icon="history" class="menu-item-icon"></fa-icon> Cash History
</button>
</div>
</div>
</mat-menu>
<mat-menu #depositsMenu="matMenu" class="deposits-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid" (click)="$event.stopPropagation()">
<div class="deposit-menu-grid deposits-grid" (click)="$event.stopPropagation()">
<div class="deposit-menu-group">
<div class="deposit-group-title">Accounts</div>
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/savings']">
@@ -1236,34 +1278,54 @@
</div>
</mat-menu>
<mat-menu #clearingMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/inward']">
<fa-icon icon="arrow-left" class="menu-item-icon"></fa-icon> Inward Clearing (CTS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/outward']">
<fa-icon icon="arrow-right" class="menu-item-icon"></fa-icon> Outward Clearing (CTS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/positive-pay']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Positive Pay (PPS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/settlement']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Settlement
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/reconciliation']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Reconciliation
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/nachclearing']">
<fa-icon icon="money-check" class="menu-item-icon"></fa-icon> NACH Clearing
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="arrow-right" class="menu-item-icon"></fa-icon> Outward</button>
<button mat-menu-item tabindex="0"><fa-icon icon="arrow-left" class="menu-item-icon"></fa-icon> Inward</button>
<button mat-menu-item [routerLink]="['/sponsor-dd/inventory']" tabindex="0">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Sponsor DD Inventory
</button>
<mat-menu #clearingMenu="matMenu" class="clearing-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid clearing-grid" (click)="$event.stopPropagation()">
<!-- CTS Clearing -->
<div class="deposit-menu-group">
<div class="deposit-group-title">CTS Clearing</div>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/inward']">
<fa-icon icon="arrow-left" class="menu-item-icon"></fa-icon> Inward Clearing (CTS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/outward']">
<fa-icon icon="arrow-right" class="menu-item-icon"></fa-icon> Outward Clearing (CTS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/positive-pay']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Positive Pay (PPS)
</button>
</div>
<!-- Settlement & Recon -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Settlement &amp; Recon</div>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/settlement']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Settlement
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/reconciliation']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Reconciliation
</button>
<button mat-menu-item [routerLink]="['/sponsor-dd/inventory']" tabindex="0">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Sponsor DD Inventory
</button>
</div>
<!-- NACH & Other -->
<div class="deposit-menu-group">
<div class="deposit-group-title">NACH &amp; Direct</div>
<button mat-menu-item tabindex="0" [routerLink]="['/nachclearing']">
<fa-icon icon="money-check" class="menu-item-icon"></fa-icon> NACH Clearing
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="arrow-right" class="menu-item-icon"></fa-icon> Outward
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="arrow-left" class="menu-item-icon"></fa-icon> Inward
</button>
</div>
</div>
</mat-menu>
<mat-menu #hrmMenu="matMenu" class="hrm-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid" (click)="$event.stopPropagation()">
<div class="deposit-menu-grid hrm-grid" (click)="$event.stopPropagation()">
<!-- Payroll -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Payroll</div>
@@ -1570,23 +1632,33 @@
</div>
</mat-menu>
<mat-menu #ckycMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item routerLink="/ckyc" tabindex="0">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> CKYC Management
</button>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('REKYC')">
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #be185d"></fa-icon> Re-KYC Report
</button>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('RISK-REVIEW')">
<fa-icon icon="history" class="menu-item-icon" style="color: #be185d"></fa-icon> Risk Review
</button>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('CERSAI')">
<fa-icon icon="shield-alt" class="menu-item-icon" style="color: #be185d"></fa-icon> CERSAI Report
</button>
<mat-divider></mat-divider>
<button mat-menu-item tabindex="0">
<fa-icon icon="database" class="menu-item-icon" style="color: #be185d"></fa-icon> Dump KYC &amp; Loan Data
</button>
<mat-menu #ckycMenu="matMenu" class="ckyc-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid ckyc-grid" (click)="$event.stopPropagation()">
<!-- KYC Operations -->
<div class="deposit-menu-group">
<div class="deposit-group-title">KYC Operations</div>
<button mat-menu-item routerLink="/ckyc" tabindex="0">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> CKYC Management
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="database" class="menu-item-icon" style="color: #be185d"></fa-icon> Dump KYC &amp; Loan Data
</button>
</div>
<!-- Reports & Screening -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Reports &amp; Screening</div>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('REKYC')">
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #be185d"></fa-icon> Re-KYC Report
</button>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('RISK-REVIEW')">
<fa-icon icon="history" class="menu-item-icon" style="color: #be185d"></fa-icon> Risk Review
</button>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('CERSAI')">
<fa-icon icon="shield-alt" class="menu-item-icon" style="color: #be185d"></fa-icon> CERSAI Report
</button>
</div>
</div>
</mat-menu>
<!-- CFCFRMS — single landing page (CfcfrmsShell) hosts the full
@@ -1604,11 +1676,11 @@
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/sundry-dr-cr">
<fa-icon icon="random" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Sundry Dr / Cr Reconciliation
Sundry Dr / Cr
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/pnl-impact">
<fa-icon icon="chart-line" class="menu-item-icon" style="color: #0d9488"></fa-icon>
P&amp;L Impact Reconciliation
P&amp;L Impact
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/gl-reconciliation">
<fa-icon icon="book-open" class="menu-item-icon" style="color: #0d9488"></fa-icon>
@@ -1616,11 +1688,11 @@
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/fixed-assets">
<fa-icon icon="cube" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Fixed Assets Reconciliation
Fixed Assets
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/inter-company">
<fa-icon icon="sitemap" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Inter-Company Reconciliation
Inter-Company
</button>
</div>
@@ -1633,15 +1705,15 @@
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/api-bank-reco">
<fa-icon icon="code-branch" class="menu-item-icon" style="color: #0d9488"></fa-icon>
API Based Bank Reconciliation
API Bank Reco
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/neft-rtgs">
<fa-icon icon="exchange" class="menu-item-icon" style="color: #0d9488"></fa-icon>
NEFT / RTGS Reconciliation
NEFT / RTGS Reco
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/clearing-reco">
<fa-icon icon="hand-holding-usd" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Clearing Reconciliation
Clearing Reco
</button>
</div>
@@ -1654,11 +1726,11 @@
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/three-way-digital">
<fa-icon icon="project-diagram" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Three-way Digital Reconciliation
Three-way Digital
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/nach-reco">
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
NACH Reconciliation
NACH Reco
</button>
</div>
@@ -1667,11 +1739,11 @@
<div class="deposit-group-title">Instruments &amp; Members</div>
<button mat-menu-item [routerLink]="['/sponsor-dd-reconciliation']" tabindex="0">
<fa-icon icon="money-check-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
DD / Pay Order Reconciliation
DD / Pay Order
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/share-member">
<fa-icon icon="coins" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Share Member Reconciliation
Share Member
</button>
</div>
@@ -1847,46 +1919,68 @@
</div>
</mat-menu>
<mat-menu #insuranceMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item routerLink="/dicgc" tabindex="0">
<fa-icon icon="building" class="menu-item-icon" style="color: #c2410c"></fa-icon> DICGC
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="heart" class="menu-item-icon"></fa-icon> PMJBY</button>
<button mat-menu-item tabindex="0"><fa-icon icon="heart" class="menu-item-icon"></fa-icon> PMSBY</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="hand-holding-heart" class="menu-item-icon"></fa-icon> Indemnity
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="home" class="menu-item-icon"></fa-icon> Assets Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="laptop" class="menu-item-icon"></fa-icon> Cyber Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="university" class="menu-item-icon"></fa-icon> Bank Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Loan Insurance
</button>
<mat-menu #insuranceMenu="matMenu" class="insurance-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid insurance-grid" (click)="$event.stopPropagation()">
<!-- Social & Policy Schemes -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Schemes &amp; Social</div>
<button mat-menu-item routerLink="/dicgc" tabindex="0">
<fa-icon icon="building" class="menu-item-icon" style="color: #c2410c"></fa-icon> DICGC
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="heart" class="menu-item-icon"></fa-icon> PMJBY</button>
<button mat-menu-item tabindex="0"><fa-icon icon="heart" class="menu-item-icon"></fa-icon> PMSBY</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="hand-holding-heart" class="menu-item-icon"></fa-icon> Indemnity
</button>
</div>
<!-- Institutional & Loans -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Institutional &amp; Loans</div>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Loan Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="home" class="menu-item-icon"></fa-icon> Assets Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="laptop" class="menu-item-icon"></fa-icon> Cyber Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="university" class="menu-item-icon"></fa-icon> Bank Insurance
</button>
</div>
</div>
</mat-menu>
<mat-menu #digitalMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item routerLink="/digital/neft-rtgs-report" tabindex="0">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> NEFT / RTGS
</button>
<button mat-menu-item routerLink="/digital/imps-report" tabindex="0">
<fa-icon icon="bolt" class="menu-item-icon"></fa-icon> IMPS
</button>
<button mat-menu-item routerLink="/digital/upi-report" tabindex="0">
<fa-icon icon="mobile-alt" class="menu-item-icon"></fa-icon> UPI Reports
</button>
<button mat-menu-item routerLink="/digital/atm-pos-ecom" tabindex="0">
<fa-icon icon="credit-card" class="menu-item-icon"></fa-icon> ATM / POS / ECOM Reports
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="sync" class="menu-item-icon"></fa-icon> NACH</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon"></fa-icon> Mandate
</button>
<mat-menu #digitalMenu="matMenu" class="digital-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid digital-grid" (click)="$event.stopPropagation()">
<!-- Payment Systems -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Payments</div>
<button mat-menu-item routerLink="/digital/neft-rtgs-report" tabindex="0">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> NEFT / RTGS
</button>
<button mat-menu-item routerLink="/digital/imps-report" tabindex="0">
<fa-icon icon="bolt" class="menu-item-icon"></fa-icon> IMPS
</button>
<button mat-menu-item routerLink="/digital/upi-report" tabindex="0">
<fa-icon icon="mobile-alt" class="menu-item-icon"></fa-icon> UPI Reports
</button>
</div>
<!-- Cards & Mandates -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Cards &amp; Mandates</div>
<button mat-menu-item routerLink="/digital/atm-pos-ecom" tabindex="0">
<fa-icon icon="credit-card" class="menu-item-icon"></fa-icon> ATM / POS / ECOM Reports
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="sync" class="menu-item-icon"></fa-icon> NACH</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon"></fa-icon> Mandate
</button>
</div>
</div>
</mat-menu>
<!-- RBI Mega Menu -->
+197 -124
View File
@@ -266,7 +266,7 @@ $color: #e2e4ec;
max-width: 100%;
overflow-x: hidden;
box-sizing: border-box;
padding: 16px 28px;
padding: 10px 18px;
}
}
@@ -282,21 +282,15 @@ $gray-700: #334155;
/* ============================================================
Main header (top 2-row navigation)
--------------------------------------------------------------
Redesigned 2026 — clean, modern enterprise SaaS feel:
• Both rows live on the same white surface separated only by a
hairline; r2 has a barely-there blue-50 wash so frequent vs
secondary menus read at a glance without dark/light contrast.
• Items are now equal-width "tabs" with NO vertical dividers
between them (the old cell borders made it look spreadsheet-y).
• Hover / active = a 3 px brand-coloured TOP bar + brand text +
coloured icon. Reads instantly without colour-flooding the row.
• Icons larger (12.5 px) and full-opacity always — easier to scan
during demos. Caret is subtle and rotates on open.
Compact, modern enterprise SaaS navigation:
• Height reduced to 32px per row (64px total) to save screen space.
• Smooth horizontal scrolling prevents menu items from getting cut off.
• Compact typography, icons and paddings fit all banking modules cleanly.
============================================================ */
.main-header {
background: #fff;
border-bottom: 2.5px solid $brand;
box-shadow: 0 1px 4px rgb(0 0 0 / 7%);
border-bottom: 2px solid $brand;
box-shadow: 0 1px 3px rgb(0 0 0 / 6%);
position: sticky;
/* Sit directly under the sticky toolbar (32px tall) so both stay visible. */
@@ -306,19 +300,19 @@ $gray-700: #334155;
}
.nav-row {
height: 44px;
height: 32px;
display: flex;
align-items: stretch;
overflow: visible;
overflow: auto visible;
position: relative;
/* Outer frame; vertical dividers between cells are drawn as `border-right`
on each `.ni` below. Switched away from the equal-column grid so each
cell can size to its own label — long names (General Ledger, Documents
Hub) no longer overflow and short ones (Approvals, HRM) no longer waste
space. */
border: 1px solid $gray-200;
background: #fff;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
.r2.nav-row {
@@ -334,25 +328,17 @@ $gray-700: #334155;
background: #f8fafc;
}
// ===== Nav Item — modern tab style =====
// Typography spec — same family / size / weight / color for BOTH rows.
// ===== Nav Item — compact modern tab style =====
.ni {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
gap: 2.5px;
height: 100%;
/* Each cell sizes to its own label (min-width: max-content) and grows
to share any leftover row width. Short labels stay compact, long ones
get the room they need — no overlap, no wasted gap. */
flex: 1 0 auto;
min-width: max-content;
padding: 0 14px;
/* Vertical divider between adjacent cells; outer frame is the row's
own border, and the last cell drops its right border below. */
min-width: 0;
padding: 0 4px;
border-right: 1px solid $gray-200;
cursor: pointer;
white-space: nowrap;
@@ -360,11 +346,11 @@ $gray-700: #334155;
'IBM Plex Sans',
-apple-system,
sans-serif;
font-size: 12px;
font-size: 10px;
font-weight: 500;
color: #0a1a2e;
line-height: 1.3;
letter-spacing: 0.01em;
line-height: 1.2;
letter-spacing: -0.015em;
text-decoration: none;
user-select: none;
transition:
@@ -373,17 +359,15 @@ $gray-700: #334155;
box-shadow 0.15s ease;
border-radius: 0;
/* 3px accent strip that fills on hover / active (uses ::before so we
don't reflow the row when toggling). */
&::before {
content: '';
position: absolute;
left: 4px;
right: 4px;
left: 2px;
right: 2px;
top: 0;
height: 3px;
height: 2px;
background: transparent;
border-radius: 0 0 3px 3px;
border-radius: 0 0 2px 2px;
transition: background 0.15s ease;
}
@@ -410,17 +394,19 @@ $gray-700: #334155;
outline-offset: -2px;
}
/* Last cell in a row sits flush against the row's own right edge, so
drop its divider to avoid a double 2 px line. */
&:last-child {
border-right: none;
}
.ni-short {
display: none;
}
}
.ni-ico {
width: 12px;
height: 12px;
font-size: 12px;
width: 9.5px;
height: 9.5px;
font-size: 9.5px;
color: $gray-500;
flex-shrink: 0;
transition: color 0.15s ease;
@@ -432,11 +418,12 @@ $gray-700: #334155;
}
.ni-caret {
width: 7px;
height: 7px;
font-size: 7px;
width: 5px;
height: 5px;
font-size: 5px;
color: $gray-400;
flex-shrink: 0;
margin-left: 0.5px;
transition:
transform 0.18s ease,
color 0.18s ease;
@@ -447,17 +434,89 @@ $gray-700: #334155;
}
}
@media (width <= 1200px) {
@media (width >= 1400px) {
.ni {
padding: 0 8px;
gap: 5px;
padding: 0 6px;
gap: 3.5px;
font-size: 10.8px;
letter-spacing: 0;
}
.ni-ico {
width: 10px;
height: 10px;
font-size: 10px;
}
.ni-caret {
width: 5.5px;
height: 5.5px;
font-size: 5.5px;
margin-left: 1px;
}
}
@media (width <= 980px) {
@media (width <= 1200px) {
.ni {
padding: 0 3px;
gap: 2px;
font-size: 9.8px;
letter-spacing: -0.02em;
}
.ni-ico {
width: 9px;
height: 9px;
font-size: 9px;
}
.ni-caret {
width: 4.5px;
height: 4.5px;
font-size: 4.5px;
}
}
@media (width <= 1080px) {
.ni {
padding: 0 2.5px;
gap: 1.5px;
font-size: 9.4px;
letter-spacing: -0.025em;
.ni-full {
display: none;
}
.ni-short {
display: inline;
}
}
.ni-ico {
width: 8.5px;
height: 8.5px;
font-size: 8.5px;
}
.ni-caret {
width: 4px;
height: 4px;
font-size: 4px;
}
}
@media (width <= 880px) {
.nav-row {
overflow-x: auto;
scrollbar-width: thin;
}
.ni {
flex: 0 0 auto;
padding: 0 6px;
gap: 4px;
gap: 3px;
font-size: 10px;
}
}
@@ -699,21 +758,29 @@ $gray-700: #334155;
// ===== Nav Dropdown Menus =====
::ng-deep .nav-dropdown {
border-radius: 8px !important;
border-radius: 6px !important;
border: 1px solid $gray-200 !important;
box-shadow: 0 6px 24px rgb(0 0 0 / 10%) !important;
min-width: 180px !important;
padding: 4px 0 !important;
margin-top: 2px !important;
box-shadow: 0 4px 16px rgb(0 0 0 / 12%) !important;
min-width: 170px !important;
padding: 3px 0 !important;
margin-top: 1px !important;
.mat-mdc-menu-item {
font-size: 12.5px !important;
font-size: 10.5px !important;
font-weight: 500 !important;
height: 34px !important;
min-height: 34px !important;
padding: 0 14px !important;
height: 25px !important;
min-height: 25px !important;
padding: 0 6px !important;
line-height: 25px !important;
color: $gray-700 !important;
font-family: 'DM Sans', Roboto, sans-serif !important;
font-family: 'IBM Plex Sans', Roboto, sans-serif !important;
white-space: nowrap !important;
.menu-item-icon {
font-size: 10.5px !important;
margin-right: 5px !important;
opacity: 0.8;
}
&:hover {
background: $brand-hover !important;
@@ -728,39 +795,44 @@ $gray-700: #334155;
}
/* Deposits Mega Panel (inside mat-menu overlay) */
.deposit-menu-grid {
::ng-deep .deposit-menu-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px 24px;
padding: 8px 12px;
min-width: 600px;
gap: 4px 12px;
padding: 6px 10px;
min-width: 0;
width: 100%;
box-sizing: border-box;
overflow-x: hidden;
}
.deposit-menu-group {
::ng-deep .deposit-menu-group {
.deposit-group-title {
font-weight: 700;
font-size: 11px;
font-size: 9.5px;
text-transform: uppercase;
letter-spacing: 0.6px;
letter-spacing: 0.5px;
color: #1074b9;
padding: 4px 16px 6px;
padding: 2px 6px 3px;
margin-bottom: 2px;
border-bottom: 2px solid #1074b9;
border-bottom: 1.5px solid #1074b9;
white-space: nowrap;
}
}
/* NPA Mega Panel — 4 columns (Reports / Analysis / Classification / Recovery Management) */
.npa-grid {
grid-template-columns: repeat(4, minmax(200px, 1fr));
min-width: 880px;
max-width: 96vw;
/* NPA Mega Panel — 3 columns */
::ng-deep .npa-grid {
grid-template-columns: repeat(3, minmax(210px, 1fr));
min-width: 0;
max-width: 100%;
overflow-x: hidden;
}
/* Reconciliation Mega Panel — 5 columns, teal accent to match the icon palette */
.reconciliation-grid {
grid-template-columns: repeat(5, minmax(190px, 1fr));
min-width: 1040px;
max-width: 96vw;
/* Reconciliation Mega Panel — 5 columns */
::ng-deep .reconciliation-grid {
grid-template-columns: repeat(5, minmax(170px, 1fr));
min-width: 0;
max-width: 100%;
overflow-x: hidden;
.deposit-group-title {
color: #0d9488;
@@ -780,47 +852,49 @@ $gray-700: #334155;
.gl-mega-menu {
position: absolute;
top: 100px;
top: 96px;
left: 50%;
transform: translateX(-50%);
background: #fff;
width: 860px;
max-width: 95vw;
padding: 20px 24px;
box-shadow: 0 6px 24px rgb(0 0 0 / 22%);
border-radius: 0 0 10px 10px;
width: 760px;
max-width: 94vw;
max-height: 72vh;
overflow-y: auto;
padding: 12px 16px;
box-shadow: 0 6px 20px rgb(0 0 0 / 18%);
border-radius: 0 0 8px 8px;
z-index: 1000;
border-top: 3px solid #0d47a1;
border-top: 2.5px solid #0d47a1;
}
.gl-mega-menu-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
padding-bottom: 12px;
gap: 8px;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid #e5e7eb;
span {
font-size: 18px;
font-size: 14px;
font-weight: 700;
color: #0d47a1;
letter-spacing: 0.02em;
letter-spacing: 0.01em;
}
}
.gl-mega-menu-header-icon {
font-size: 18px;
font-size: 14px;
color: #0d47a1;
}
.gl-search {
width: 100%;
padding: 10px 14px;
margin-bottom: 16px;
padding: 6px 10px;
margin-bottom: 10px;
border: 1px solid #d0d5dd;
border-radius: 6px;
font-size: 14px;
border-radius: 5px;
font-size: 12px;
box-sizing: border-box;
background: #f9fafb;
@@ -828,36 +902,37 @@ $gray-700: #334155;
outline: none;
border-color: #0d47a1;
background: #fff;
box-shadow: 0 0 0 3px rgb(13 71 161 / 10%);
box-shadow: 0 0 0 2px rgb(13 71 161 / 10%);
}
}
.gl-columns {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
gap: 10px 16px;
}
.gl-group-title {
font-weight: 700;
font-size: 11px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.8px;
letter-spacing: 0.6px;
color: #0d47a1;
margin-bottom: 10px;
padding-bottom: 6px;
border-bottom: 2px solid #0d47a1;
margin-bottom: 5px;
padding-bottom: 3px;
border-bottom: 1.5px solid #0d47a1;
}
.gl-link {
display: flex;
align-items: center;
padding: 7px 6px;
padding: 3.5px 5px;
text-decoration: none;
color: #344054;
font-size: 13px;
font-size: 11.5px;
line-height: 1.3;
cursor: pointer;
border-radius: 5px;
border-radius: 4px;
transition:
background 0.15s,
color 0.15s;
@@ -873,23 +948,21 @@ $gray-700: #334155;
}
.gl-icon {
width: 18px;
margin-right: 8px;
width: 14px;
margin-right: 5px;
color: #667085;
font-size: 13px;
font-size: 11px;
flex-shrink: 0;
.gl-link:hover & {
color: #0d47a1;
}
}
// Marks a menu link as actually wired to a working screen/dialog (vs a still-unwired
// placeholder) — a small green dot at the end of the row, so a reviewer can tell at a glance
// which items in a mega-menu are functional without having to click every one of them.
.gl-link--live::after {
content: '';
width: 6px;
height: 6px;
width: 5px;
height: 5px;
margin-left: auto;
flex-shrink: 0;
border-radius: 50%;
@@ -899,9 +972,9 @@ $gray-700: #334155;
/* RBI Mega Menu */
.rbi-mega-menu {
width: 1100px;
max-width: 97vw;
max-height: 80vh;
width: 880px;
max-width: 94vw;
max-height: 72vh;
overflow-y: auto;
border-top-color: #b71c1c;
}
@@ -909,7 +982,7 @@ $gray-700: #334155;
.rbi-columns {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
gap: 10px 16px;
}
@keyframes move {
@@ -522,7 +522,7 @@
</mat-menu>
<!-- Application User Help Menu -->
<mat-menu #applicationMenu="matMenu" [overlapTrigger]="false">
<mat-menu #applicationMenu="matMenu" [overlapTrigger]="false" class="t-user-menu" xPosition="before">
<button mat-menu-item (click)="help()" tabindex="0">
<mat-icon matListIcon>
<fa-icon icon="question-circle" size="sm"></fa-icon>
+187 -59
View File
@@ -390,6 +390,103 @@ $icon-hover-border: rgb(255 255 255 / 28%);
max-height: 80vh;
overflow-y: auto;
}
// Compact card per AI module
.ai-card.mat-mdc-menu-item {
display: flex !important;
align-items: flex-start !important;
gap: 10px !important;
padding: 10px !important;
min-height: 52px !important;
height: auto !important;
border-radius: 9px !important;
font-family: inherit !important;
line-height: 1.3 !important;
white-space: normal !important;
overflow: visible !important;
transition: background 0.16s ease !important;
.mat-mdc-menu-item-text,
.mdc-list-item__primary-text,
.mdc-list-item__content {
display: flex !important;
align-items: flex-start !important;
gap: 10px !important;
width: 100% !important;
white-space: normal !important;
overflow: visible !important;
}
.ai-card-ic {
width: 32px !important;
height: 32px !important;
min-width: 32px !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
border-radius: 8px !important;
font-size: 13px !important;
flex-shrink: 0 !important;
background: var(--c-bg) !important;
color: var(--c-color) !important;
margin: 0 !important;
opacity: 1 !important;
transition:
background 0.16s ease,
color 0.16s ease,
transform 0.16s ease !important;
fa-icon {
font-size: 13px !important;
width: auto !important;
min-width: auto !important;
margin: 0 !important;
color: inherit !important;
opacity: 1 !important;
}
}
.ai-card-body {
display: flex !important;
flex-direction: column !important;
flex: 1 !important;
min-width: 0 !important;
text-align: left !important;
white-space: normal !important;
}
.ai-card-t {
font-size: 12.5px !important;
font-weight: 600 !important;
color: #0f172a !important;
line-height: 1.25 !important;
white-space: normal !important;
display: block !important;
}
.ai-card-d {
font-size: 10.5px !important;
color: #64748b !important;
margin-top: 3px !important;
line-height: 1.35 !important;
white-space: normal !important;
display: block !important;
}
&:hover {
background: var(--c-bg) !important;
.ai-card-ic {
background: var(--c-hover) !important;
color: #fff !important;
transform: scale(1.06) !important;
}
.ai-card-t {
color: var(--c-color) !important;
}
}
}
}
.ai-menu-header {
@@ -488,70 +585,101 @@ $icon-hover-border: rgb(255 255 255 / 28%);
font-size: 11px;
}
// Compact card per AI module
.ai-card.mat-mdc-menu-item {
display: flex !important;
align-items: flex-start;
gap: 10px;
padding: 9px 10px;
min-height: 0;
height: auto;
border-radius: 9px;
font-family: inherit;
line-height: 1.3;
white-space: normal;
transition: background 0.16s ease;
// ===== User Account Dropdown Menu =====
::ng-deep .t-user-menu.mat-mdc-menu-panel {
min-width: 190px !important;
border-radius: 10px !important;
box-shadow:
0 10px 25px rgb(15 23 42 / 12%),
0 2px 6px rgb(15 23 42 / 6%) !important;
border: 1px solid #e2e8f0 !important;
padding: 6px 0 !important;
background: #fff !important;
.ai-card-ic {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
font-size: 13px;
flex-shrink: 0;
background: var(--c-bg);
color: var(--c-color);
.mat-mdc-menu-content {
padding: 4px 0 !important;
}
.mat-mdc-menu-item {
height: 38px !important;
min-height: 38px !important;
line-height: 38px !important;
padding: 0 14px !important;
display: flex !important;
align-items: center !important;
font-size: 13px !important;
font-weight: 500 !important;
color: #334155 !important;
transition:
background 0.16s ease,
color 0.16s ease,
transform 0.16s ease;
}
background-color 0.15s ease,
color 0.15s ease !important;
.ai-card-body {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
text-align: left;
}
.ai-card-t {
font-size: 12.5px;
font-weight: 600;
color: #0f172a;
line-height: 1.25;
}
.ai-card-d {
font-size: 10.5px;
color: #64748b;
margin-top: 2px;
line-height: 1.35;
}
&:hover {
background: var(--c-bg);
.ai-card-ic {
background: var(--c-hover);
color: #fff;
transform: scale(1.06);
.mat-mdc-menu-item-text {
display: inline-flex !important;
align-items: center !important;
width: 100% !important;
font-size: 13px !important;
color: inherit !important;
font-weight: 500 !important;
}
.ai-card-t {
color: var(--c-color);
mat-icon {
margin-right: 10px !important;
width: 18px !important;
height: 18px !important;
min-width: 18px !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
color: #64748b !important;
font-size: 14px !important;
flex-shrink: 0 !important;
fa-icon {
font-size: 14px !important;
width: 14px !important;
min-width: 14px !important;
text-align: center !important;
margin-right: 0 !important;
color: inherit !important;
opacity: 1 !important;
}
}
span {
font-size: 13px !important;
color: inherit !important;
font-weight: 500 !important;
white-space: nowrap !important;
}
&:hover {
background-color: #f1f5f9 !important;
color: #0d47a1 !important;
mat-icon {
color: #0d47a1 !important;
}
}
&#logout {
color: #dc2626 !important;
border-top: 1px solid #f1f5f9 !important;
margin-top: 4px !important;
padding-top: 2px !important;
mat-icon {
color: #dc2626 !important;
}
&:hover {
background-color: #fef2f2 !important;
color: #b91c1c !important;
mat-icon {
color: #b91c1c !important;
}
}
}
}
}
@@ -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 });
}
}
@@ -176,9 +176,8 @@
<!-- ===================== 01 PURCHASE ENTRY ===================== -->
@if (activeTab === 'purchase') {
<div class="pg-card">
<!-- 1. Deal details — the only fields the dealer has to fill -->
<div class="pos-section-label">
Purchase Entry — {{ instrumentLabel(purchaseForm.bondType) }} (fields marked * are mandatory)
Investment - Govt.Security/Bond Entry ({{ instrumentLabel(purchaseForm.bondType) }})
</div>
<div class="inv-form-grid">
<label class="inv-form-field">
@@ -189,6 +188,17 @@
}
</select>
</label>
<label class="inv-form-field"
><span>Deal No</span><input type="text" [(ngModel)]="purchaseForm.dealNo" readonly
/></label>
<label class="inv-form-field"
><span>* Deal Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.dealDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>* Deal Time (24 hr, HH:mm:ss)</span
><input type="text" [(ngModel)]="purchaseForm.dealTime" placeholder="HH:mm:ss"
/></label>
<label class="inv-form-field"><span>* ISIN</span><input type="text" [(ngModel)]="purchaseForm.isin" /></label>
<label class="inv-form-field">
<span>* Script Code</span>
<select [(ngModel)]="purchaseForm.scriptCode" (ngModelChange)="onScriptChange()">
@@ -198,46 +208,167 @@
}
</select>
</label>
<label class="inv-form-field">
<span>* Party Code</span>
<select [(ngModel)]="purchaseForm.partyCode">
<option value="">-- Select --</option>
@for (p of activePartyOptions; track p.partyCode) {
<option [value]="p.partyCode">{{ p.partyCode }} — {{ p.partyName }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>* Deal Date</span
><mifosx-date-ddmmyyyy
[(ngModel)]="purchaseForm.dealDate"
(ngModelChange)="onDealDateChange()"
></mifosx-date-ddmmyyyy
></label>
><span>* Cert No</span><input type="text" [(ngModel)]="purchaseForm.certNo"
/></label>
<label class="inv-form-field">
<span>* Settlement Type</span>
<span>Settlement Type</span>
<select [(ngModel)]="purchaseForm.settlementType">
<option value="T+0">T+0</option>
<option value="T+1">T+1</option>
<option value="T+2">T+2</option>
</select>
</label>
<label class="inv-form-field"
><span>Settlement Dt</span
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.settlementDt"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field">
<span>* Party Code</span>
<select [(ngModel)]="purchaseForm.partyCode">
<option value="">-- Select --</option>
@for (p of partyOptions; track p.partyCode) {
<option [value]="p.partyName">{{ p.partyName }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Sub Type</span>
<select [(ngModel)]="purchaseForm.subType">
@for (s of subTypeOptions; track s.subTypeCode + s.subTypeName) {
<option [value]="s.subTypeName">{{ s.subTypeName }}</option>
}
</select>
</label>
@if (isMutualFund) {
<label class="inv-form-field">
<span>* Product Name</span>
<select [(ngModel)]="purchaseForm.productName">
<option value="">-- Select --</option>
@for (p of mfProductOptions; track p.productCode) {
<option [value]="p.productName">{{ p.productName }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>* Consider Amt (₹)</span><input type="number" [(ngModel)]="purchaseForm.considerAmt" min="0"
><span>Holding Days</span><input type="number" [(ngModel)]="purchaseForm.holdingDays" min="0"
/></label>
<label class="inv-form-field"
><span>* NAV</span><input type="number" [(ngModel)]="purchaseForm.nav" min="0" step="0.0001"
><span>* Consider Amt</span><input type="number" [(ngModel)]="purchaseForm.considerAmt" min="0"
/></label>
<label class="inv-form-field"
><span>Trade Amt</span><input type="number" [(ngModel)]="purchaseForm.tradeAmt" min="0"
/></label>
<label class="inv-form-field"
><span>NAV</span><input type="number" [(ngModel)]="purchaseForm.nav" min="0" step="0.0001"
/></label>
<label class="inv-form-field"
><span>Stamp Duty</span><input type="number" [(ngModel)]="purchaseForm.stampDuty" min="0"
/></label>
<label class="inv-form-field"
><span>StampDuty A/c</span><input type="text" [(ngModel)]="purchaseForm.stampDutyAc"
/></label>
} @else {
<label class="inv-form-field"
><span>* Face Value (₹)</span><input type="number" [(ngModel)]="purchaseForm.faceValue" min="0"
><span>Face Value</span><input type="number" [(ngModel)]="purchaseForm.faceValue" min="0"
/></label>
<label class="inv-form-field"
><span>* Purchase Rate (per ₹100)</span
><span>Purchase Rate (per 100)</span
><input type="number" [(ngModel)]="purchaseForm.purchaseRate" min="0" step="0.0001"
/></label>
<label class="inv-form-field"
><span>Unit Val</span><input type="text" [value]="purchaseUnitVal" readonly
/></label>
<label class="inv-form-field"
><span>Book Value</span><input type="text" [value]="purchaseBookValue | number: '1.2-2'" readonly
/></label>
<label class="inv-form-field"
><span>Premium Amt.</span><input type="number" [(ngModel)]="purchaseForm.premiumAmt"
/></label>
}
<label class="inv-form-field"
><span>* No Unit</span><input type="text" [value]="purchaseUnits" readonly
/></label>
<label class="inv-form-field"
><span>* CR. To A/c</span><input type="text" [(ngModel)]="purchaseForm.crToAc"
/></label>
<label class="inv-form-field"
><span>TBill GL.A/C.</span><input type="text" [(ngModel)]="purchaseForm.tbillGlAc"
/></label>
<label class="inv-form-field"
><span>Brokerage</span><input type="number" [(ngModel)]="purchaseForm.brokerage" min="0"
/></label>
<label class="inv-form-field">
<span>Broker Nm.</span>
<select [(ngModel)]="purchaseForm.brokerName">
<option value="">-- Select --</option>
@for (b of brokerOptions; track b.brokerCode) {
<option [value]="b.brokerCode">{{ b.brokerCode }} — {{ b.brokerName }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Trade No.</span><input type="text" [(ngModel)]="purchaseForm.tradeNo"
/></label>
<label class="inv-form-field"
><span>Order No.</span><input type="text" [(ngModel)]="purchaseForm.orderNo"
/></label>
<label class="inv-form-field">
<span>Recomm. Authority</span>
<select [(ngModel)]="purchaseForm.recommAuthority">
<option value="">-- Select --</option>
@for (a of authorityOptions; track a) {
<option [value]="a">{{ a }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Sanction Authority</span>
<select [(ngModel)]="purchaseForm.sanctionAuthority">
<option value="">-- Select --</option>
@for (a of authorityOptions; track a) {
<option [value]="a">{{ a }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>* Issue Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.issueDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Period(Year)</span><input type="number" [(ngModel)]="purchaseForm.periodYear" min="0"
/></label>
<label class="inv-form-field"
><span>Period(Month)</span><input type="number" [(ngModel)]="purchaseForm.periodMonth" min="0" max="11"
/></label>
<label class="inv-form-field"
><span>Period(Days)</span><input type="number" [(ngModel)]="purchaseForm.periodDays" min="0"
/></label>
<label class="inv-form-field"
><span>Desc Inv</span><input type="text" [(ngModel)]="purchaseForm.descInv"
/></label>
<label class="inv-form-field"
><span>Folio No</span><input type="text" [(ngModel)]="purchaseForm.folioNo"
/></label>
<label class="inv-form-field"
><span>Call Option</span><input type="text" [(ngModel)]="purchaseForm.callOption"
/></label>
<label class="inv-form-field"
><span>Meet Dt</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.meetDt"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Reso No</span><input type="text" [(ngModel)]="purchaseForm.resoNo"
/></label>
<label class="inv-form-field"
><span>From Dist</span><input type="text" [(ngModel)]="purchaseForm.fromDist"
/></label>
<label class="inv-form-field"
><span>To Dist</span><input type="text" [(ngModel)]="purchaseForm.toDist"
/></label>
<label class="inv-form-field"
><span>Redum/Mat Dt</span
><input type="text" [(ngModel)]="purchaseForm.redumMatDt" placeholder="DD-MM-YYYY (blank = use period)"
/></label>
<label class="inv-form-field">
<span>* Category</span>
<select [(ngModel)]="purchaseForm.category">
@@ -249,211 +380,32 @@
</label>
@if (!isMutualFund) {
<label class="inv-form-field"
><span>* Last Interest Date</span
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.lastIntDt"></mifosx-date-ddmmyyyy
></label>
}
<label class="inv-form-field">
<span>* CR. To A/c (Paid From)</span>
<select [(ngModel)]="purchaseForm.crToAc">
@for (g of settlementBankOptions; track g.glCode) {
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
}
</select>
</label>
</div>
<!-- 2. Calculated — filled by the system, read-only -->
<div class="pos-section-label">Calculated by System</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Deal No</span><input type="text" [value]="purchaseForm.dealNo" readonly
/></label>
<label class="inv-form-field"
><span>Cert No</span><input type="text" [value]="purchaseForm.certNo" readonly
/></label>
<label class="inv-form-field"
><span>Settlement Date</span><input type="text" [value]="purchaseSettlementDate" readonly
/></label>
<label class="inv-form-field"
><span>ISIN</span><input type="text" [value]="purchaseForm.isin || '—'" readonly
/></label>
<label class="inv-form-field"
><span>Sub Type</span><input type="text" [value]="purchaseForm.subType || '—'" readonly
/></label>
@if (!isMutualFund) {
<label class="inv-form-field"
><span>Int Rate %</span><input type="text" [value]="purchaseForm.intRate ?? '—'" readonly
><span>Int Rate</span><input type="number" [(ngModel)]="purchaseForm.intRate" min="0" step="0.01"
/></label>
<label class="inv-form-field"
><span>Issue Date</span><input type="text" [value]="purchaseForm.issueDate || '—'" readonly
/></label>
<label class="inv-form-field"
><span>Maturity Date</span><input type="text" [value]="purchaseMaturityDate || '—'" readonly
/></label>
<label class="inv-form-field"
><span>Period (Y / M / D)</span
><input
type="text"
[value]="purchaseForm.periodYear + ' / ' + purchaseForm.periodMonth + ' / ' + purchaseForm.periodDays"
readonly
/></label>
}
<label class="inv-form-field"
><span>No. of Units</span><input type="text" [value]="purchaseUnits | number: '1.0-4'" readonly
/></label>
<label class="inv-form-field"
><span>Book Value</span><input type="text" [value]="'₹' + (purchaseBookValue | number: '1.2-2')" readonly
/></label>
@if (!isMutualFund) {
<label class="inv-form-field"
><span>Premium (+) / Discount (−)</span
><input type="text" [value]="'₹' + (purchasePremiumDiscount | number: '1.0-0')" readonly
/></label>
<label class="inv-form-field"
><span>Accrued Interest ({{ purchaseAccruedDays }} days)</span
><input type="text" [value]="'₹' + (purchaseAccruedInterest | number: '1.2-2')" readonly
/></label>
<label class="inv-form-field"><span>YTM %</span><input type="text" [value]="purchaseYtm" readonly /></label>
}
<label class="inv-form-field"
><span>Total Settlement Amount</span
><input type="text" [value]="'₹' + (purchaseSettlementAmount | number: '1.2-2')" readonly
/></label>
</div>
<!-- 3. Accounting preview -->
<div class="pos-section-label">Accounting Preview (posted after checker approval)</div>
@if (purchaseAccountingPreview.length) {
<table class="pg-table">
<thead>
<tr>
<th>Dr / Cr</th>
<th>GL Code</th>
<th>GL Name</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (l of purchaseAccountingPreview; track l.side + l.glCode) {
<tr>
<td>{{ l.side }}</td>
<td>{{ l.glCode }}</td>
<td>{{ l.glName || '—' }}</td>
<td class="num">₹{{ l.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<p class="pg-sub">Select a script, enter the amount and CR. To A/c to see the entry.</p>
}
<!-- 4. More details — optional, collapsed by default -->
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--ghost" (click)="showMoreDetails = !showMoreDetails">
{{ showMoreDetails ? '▾ Hide More Details' : '▸ More Details (optional)' }}
</button>
</div>
@if (showMoreDetails) {
<div class="pos-section-label">More Details (optional)</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Deal Time (HH:mm:ss)</span
><input type="text" [(ngModel)]="purchaseForm.dealTime" placeholder="HH:mm:ss"
><span>YTM (computed)</span><input type="text" [value]="purchaseYtm" readonly
/></label>
<label class="inv-form-field">
<span>Sub Type</span>
<select [(ngModel)]="purchaseForm.subType">
@for (s of subTypeOptions; track s.subTypeCode + s.subTypeName) {
<option [value]="s.subTypeName">{{ s.subTypeName }}</option>
}
</select>
</label>
@if (isMutualFund) {
<label class="inv-form-field"
><span>Trade Amt</span><input type="number" [(ngModel)]="purchaseForm.tradeAmt" min="0"
/></label>
<label class="inv-form-field"
><span>Stamp Duty</span><input type="number" [(ngModel)]="purchaseForm.stampDuty" min="0"
/></label>
<label class="inv-form-field"
><span>Stamp Duty A/c</span><input type="text" [(ngModel)]="purchaseForm.stampDutyAc"
/></label>
<label class="inv-form-field"
><span>Holding Days</span><input type="number" [(ngModel)]="purchaseForm.holdingDays" min="0"
/></label>
} @else {
<label class="inv-form-field">
<span>Int Type</span>
<select [(ngModel)]="purchaseForm.intType">
@for (i of intTypeOptions; track i.value) {
<option [value]="i.value">{{ i.label }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Bond Rating</span>
<select [(ngModel)]="purchaseForm.bondRating">
@for (r of bondRatingOptions; track r) {
<option [value]="r">{{ r }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>SGL with Bank</span>
<select [(ngModel)]="purchaseForm.sglWithBank">
<option value="">-- Select --</option>
@for (s of sglBankOptions; track s.sglCode) {
<option [value]="s.bankName">{{ s.bankName }} — {{ s.branch }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>TBill GL A/c</span><input type="text" [(ngModel)]="purchaseForm.tbillGlAc"
/></label>
}
<label class="inv-form-field"
><span>Brokerage (₹)</span><input type="number" [(ngModel)]="purchaseForm.brokerage" min="0"
/></label>
<label class="inv-form-field">
<span>Broker</span>
<select [(ngModel)]="purchaseForm.brokerName">
<option value="">-- Select --</option>
@for (b of brokerOptions; track b.brokerCode) {
<option [value]="b.brokerCode">{{ b.brokerCode }} — {{ b.brokerName }}</option>
<span>Int Type</span>
<select [(ngModel)]="purchaseForm.intType">
@for (i of intTypeOptions; track i.value) {
<option [value]="i.value">{{ i.label }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Trade No.</span><input type="text" [(ngModel)]="purchaseForm.tradeNo"
/></label>
<label class="inv-form-field"
><span>Order No.</span><input type="text" [(ngModel)]="purchaseForm.orderNo"
/></label>
<label class="inv-form-field">
<span>Recomm. Authority</span>
<select [(ngModel)]="purchaseForm.recommAuthority">
<option value="">-- Select --</option>
@for (a of authorityOptions; track a) {
<option [value]="a">{{ a }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Sanction Authority</span>
<select [(ngModel)]="purchaseForm.sanctionAuthority">
<option value="">-- Select --</option>
@for (a of authorityOptions; track a) {
<option [value]="a">{{ a }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Meet Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.meetDt"></mifosx-date-ddmmyyyy
><span>Int Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.intDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Reso No</span><input type="text" [(ngModel)]="purchaseForm.resoNo"
><span>* Last Int.dt</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.lastIntDt"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Accrued Interest (computed)</span
><input type="text" [value]="purchaseAccruedInterest | number: '1.2-2'" readonly
/></label>
<label class="inv-form-field"
><span>Settlement Amt of Pur. (computed)</span
><input type="text" [value]="purchaseSettlementAmount | number: '1.4-4'" readonly
/></label>
<label class="inv-form-field">
<span>Deal Type</span>
@@ -471,80 +423,6 @@
}
</select>
</label>
<label class="inv-form-field">
<span>Payment Mode</span>
<select [(ngModel)]="purchaseForm.paymentMode">
<option value="">-- Select --</option>
@for (p of paymentModeOptions; track p) {
<option [value]="p">{{ p }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Delivery Mode</span>
<select [(ngModel)]="purchaseForm.deliveryMode">
<option value="">-- Select --</option>
@for (d of deliveryModeOptions; track d) {
<option [value]="d">{{ d }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>LIEN</span>
<select [(ngModel)]="purchaseForm.lien">
<option value="NO">NO</option>
<option value="YES">YES</option>
</select>
</label>
@if (purchaseForm.lien === 'YES') {
<label class="inv-form-field"
><span>LIEN Amount</span><input type="number" [(ngModel)]="purchaseForm.lienAmount"
/></label>
<label class="inv-form-field">
<span>LIEN Reason</span>
<select [(ngModel)]="purchaseForm.lienReason">
<option value="">-- Select --</option>
@for (l of lienReasonOptions; track l.reasonCode) {
<option [value]="l.reasonDescription">{{ l.reasonDescription }}</option>
}
</select>
</label>
}
<label class="inv-form-field"
><span>Folio No</span><input type="text" [(ngModel)]="purchaseForm.folioNo"
/></label>
<label class="inv-form-field"
><span>Call Option</span><input type="text" [(ngModel)]="purchaseForm.callOption"
/></label>
<label class="inv-form-field"
><span>Register Address</span><input type="text" [(ngModel)]="purchaseForm.registerAdd"
/></label>
<label class="inv-form-field"
><span>Register City</span><input type="text" [(ngModel)]="purchaseForm.regiCity"
/></label>
<label class="inv-form-field"
><span>Register Pin</span><input type="text" [(ngModel)]="purchaseForm.regiPin"
/></label>
<label class="inv-form-field"
><span>Issue Date (override)</span
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.issueDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Redum / Mat Date (override)</span
><input type="text" [(ngModel)]="purchaseForm.redumMatDt" placeholder="DD-MM-YYYY"
/></label>
<label class="inv-form-field"
><span>Int Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.intDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Desc Inv</span><input type="text" [(ngModel)]="purchaseForm.descInv"
/></label>
<label class="inv-form-field"
><span>From Dist</span><input type="text" [(ngModel)]="purchaseForm.fromDist"
/></label>
<label class="inv-form-field"
><span>To Dist</span><input type="text" [(ngModel)]="purchaseForm.toDist"
/></label>
<label class="inv-form-field">
<span>Mark CBLO</span>
<select [(ngModel)]="purchaseForm.markCblo">
@@ -555,6 +433,14 @@
<label class="inv-form-field"
><span>Amortize Amt.</span><input type="number" [(ngModel)]="purchaseForm.amortizeAmt"
/></label>
<label class="inv-form-field">
<span>Bond Rating</span>
<select [(ngModel)]="purchaseForm.bondRating">
@for (r of bondRatingOptions; track r) {
<option [value]="r">{{ r }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Corp. Bond</span>
<select [(ngModel)]="purchaseForm.corpBond">
@@ -562,19 +448,76 @@
<option value="YES">YES</option>
</select>
</label>
<label class="inv-form-field">
<span>SGL with Bank</span>
<select [(ngModel)]="purchaseForm.sglWithBank">
<option value="">-- Select --</option>
@for (s of sglBankOptions; track s.sglCode) {
<option [value]="s.bankName">{{ s.bankName }} — {{ s.branch }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Remarks</span><input type="text" [(ngModel)]="purchaseForm.remarks"
><span>Register Add</span><input type="text" [(ngModel)]="purchaseForm.registerAdd"
/></label>
<label class="inv-form-field"
><span>Description</span><input type="text" [(ngModel)]="purchaseForm.description"
><span>Regi City</span><input type="text" [(ngModel)]="purchaseForm.regiCity"
/></label>
</div>
}
<div class="inv-form-actions">
<label class="inv-form-field"
><span>Regi Pin</span><input type="text" [(ngModel)]="purchaseForm.regiPin"
/></label>
}
<label class="inv-form-field">
<span>LIEN</span>
<select [(ngModel)]="purchaseForm.lien">
<option value="NO">NO</option>
<option value="YES">YES</option>
</select>
</label>
@if (purchaseForm.lien === 'YES') {
<label class="inv-form-field"
><span>LIEN Amount</span><input type="number" [(ngModel)]="purchaseForm.lienAmount"
/></label>
<label class="inv-form-field">
<span>LIEN Reason</span>
<select [(ngModel)]="purchaseForm.lienReason">
<option value="">-- Select --</option>
@for (l of lienReasonOptions; track l.reasonCode) {
<option [value]="l.reasonDescription">{{ l.reasonDescription }}</option>
}
</select>
</label>
}
<label class="inv-form-field">
<span>Payment Mode</span>
<select [(ngModel)]="purchaseForm.paymentMode">
<option value="">--Select--</option>
@for (p of paymentModeOptions; track p) {
<option [value]="p">{{ p }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Delivery Mode</span>
<select [(ngModel)]="purchaseForm.deliveryMode">
<option value="">--Select--</option>
@for (d of deliveryModeOptions; track d) {
<option [value]="d">{{ d }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Retrieve Cert No</span><input type="text" placeholder="e.g. 30" [(ngModel)]="retrieveCertNo"
><span>Remarks</span><input type="text" [(ngModel)]="purchaseForm.remarks"
/></label>
<label class="inv-form-field"
><span>Description</span><input type="text" [(ngModel)]="purchaseForm.description"
/></label>
<label class="inv-form-field"
><span>Maturity / Redemption Value (computed)</span
><input type="text" [value]="'₹' + (purchaseMaturityAmount | number: '1.2-2')" readonly
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="savePurchase()">Save</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="retrievePurchase()">Retrieve</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="dropPurchase()">Drop</button>
@@ -626,14 +569,20 @@
</div>
}
<!-- ===================== 03 INTEREST ENTRY ===================== -->
<!-- ===================== 03 INTEREST ENTRY / CALCULATION ===================== -->
@if (activeTab === 'interest') {
<div class="pg-card">
<div class="pos-section-label">Interest Entry — coupon and 31 Mar / 30 Sep provision schedule</div>
<div class="pos-section-label">Investment - Govt./Bond Interest calculation</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>From Date</span><mifosx-date-ddmmyyyy [(ngModel)]="intCalcFromDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>To Date</span><mifosx-date-ddmmyyyy [(ngModel)]="intCalcToDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field">
<span>Type</span>
<select [(ngModel)]="intType" (ngModelChange)="onIntFilterChange()">
<select [(ngModel)]="intCalcType" (ngModelChange)="onIntCalcTypeChange()">
<option value="">-- All Types --</option>
@for (o of bondTypeOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
@@ -642,149 +591,67 @@
</label>
<label class="inv-form-field">
<span>Script</span>
<select [(ngModel)]="intScript" (ngModelChange)="onIntFilterChange()">
<select [(ngModel)]="intCalcScript">
<option value="">-- All Scripts --</option>
@for (s of intScriptOptions; track s.scriptCode) {
@for (s of scriptOptions; track s.scriptCode) {
<option [value]="s.scriptCode">{{ s.scriptCode }} — {{ s.scriptName }}</option>
}
</select>
</label>
<label class="inv-form-field"><span>Deal No</span><input type="text" [(ngModel)]="intCalcDealNo" /></label>
<label class="inv-form-field"
><span>ALL</span><input type="checkbox" [(ngModel)]="intCalcAll" style="height: 18px; width: 18px"
/></label>
<label class="inv-form-field">
<span>* Deal No</span>
<select [(ngModel)]="intPurchaseId" (ngModelChange)="intSchedule = null">
<option [ngValue]="null">-- Select --</option>
@for (h of intDealOptions; track h.id) {
<option [ngValue]="h.id">{{ h.dealNo }} — {{ h.scriptCode }} (Cert {{ h.certNo }})</option>
<span>Post To (Interest Income GL)</span>
<select [(ngModel)]="intCalcGlCode">
@for (g of interestIncomeGlOptions; track g.glCode) {
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>As On Date</span><mifosx-date-ddmmyyyy [(ngModel)]="intAsOnDate"></mifosx-date-ddmmyyyy
></label>
</div>
<div class="inv-form-actions">
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="intLoading"
(click)="retrieveInterestSchedule()"
>
{{ intLoading ? 'Retrieving…' : 'Retrieve' }}
</button>
<button type="button" class="pg-btn pg-btn--primary" (click)="processInterest()">Process</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="setTab('holdings')">Exit</button>
</div>
@if (intSchedule; as sc) {
<div class="pos-section-label">Purchase Details</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Deal No / Cert No</span><input type="text" [value]="sc.dealNo + ' / ' + sc.certNo" readonly
/></label>
<label class="inv-form-field"
><span>Script</span><input type="text" [value]="sc.scriptCode + ' — ' + sc.scriptDesc" readonly
/></label>
<label class="inv-form-field"
><span>Face Value Held</span><input type="text" [value]="'₹' + (sc.faceValue | number: '1.2-2')" readonly
/></label>
<label class="inv-form-field"
><span>Interest Rate</span><input type="text" [value]="sc.intRate + '%'" readonly
/></label>
<label class="inv-form-field"
><span>Coupon per Half-Year</span
><input type="text" [value]="'₹' + (sc.couponPerHalfYear | number: '1.2-2')" readonly
/></label>
<label class="inv-form-field"
><span>Deal Date</span><input type="text" [value]="sc.dealDate" readonly
/></label>
<label class="inv-form-field"
><span>Last Interest Date</span><input type="text" [value]="sc.lastIntDate" readonly
/></label>
<label class="inv-form-field"
><span>Maturity Date</span><input type="text" [value]="sc.maturityDate" readonly
/></label>
<label class="inv-form-field"
><span>Accrued Interest Paid at Purchase</span
><input type="text" [value]="'₹' + (sc.purchasedAccruedInterest | number: '1.2-2')" readonly
/></label>
<label class="inv-form-field"
><span>Book Value</span><input type="text" [value]="'₹' + (sc.bookValue | number: '1.2-2')" readonly
/></label>
</div>
<div class="pos-section-label">
Interest Schedule ({{ sc.rows.length }} lines) — due up to {{ sc.asOnDate }}: ₹{{
intDueTotal | number: '1.2-2'
}}
</div>
<p class="pg-sub" style="padding: 0 16px">
Days are 30/360. A provision books the interest earned since the last coupon; the coupon then clears what is
already booked. Display only — nothing is posted to GL yet.
</p>
<table class="pg-table">
<thead>
<tr>
<th>Type</th>
<th>Int Date</th>
<th>From</th>
<th>To</th>
<th class="num">Days</th>
<th class="num">Int %</th>
<th class="num">Gross Interest</th>
<th class="num">Already Booked</th>
<th class="num col-in">Amount</th>
<th>Accounting</th>
</tr>
</thead>
<tbody>
@for (r of sc.rows; track r.intDate) {
<tr [style.opacity]="r.due ? 1 : 0.6">
<td>
<span class="pg-chip">{{ r.type === 'COUPON' ? 'Coupon' : 'Provision' }}</span>
@if (r.due) {
<span class="pg-chip">Due</span>
}
</td>
<td>{{ r.intDate }}</td>
<td>{{ r.fromDate }}</td>
<td>{{ r.toDate }}</td>
<td class="num">{{ r.days }}</td>
<td class="num">{{ sc.intRate }}</td>
<td class="num">₹{{ r.grossInterest | number: '1.2-2' }}</td>
<td class="num">₹{{ r.alreadyBooked | number: '1.2-2' }}</td>
<td class="num col-in">₹{{ r.amount | number: '1.2-2' }}</td>
<td style="white-space: nowrap; font-size: 12px">
@if (r.type === 'COUPON') {
<div>Dr Bank ₹{{ r.grossInterest | number: '1.0-0' }}</div>
@if (r.alreadyBooked > 0) {
<div>
Cr {{ interestReceivableGl.glCode }} Int. Receivable ₹{{ r.alreadyBooked | number: '1.0-0' }}
</div>
}
<div>Cr {{ sc.interestGlCode }} Int. Income ₹{{ r.amount | number: '1.0-0' }}</div>
} @else {
<div>Dr {{ interestReceivableGl.glCode }} Int. Receivable ₹{{ r.amount | number: '1.0-0' }}</div>
<div>Cr {{ sc.interestGlCode }} Int. Income ₹{{ r.amount | number: '1.0-0' }}</div>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="10" class="pg-empty">
No interest lines — check the rate, last interest date and maturity date.
</td>
</tr>
}
</tbody>
</table>
<p class="pg-sub" style="padding: 8px 16px 12px">
Interest income GL: {{ sc.interestGlCode }} — {{ sc.interestGlName }}. Posting to GL will be enabled after
approval.
</p>
} @else {
@if (intCalcExcludedNpi.length > 0) {
<p class="pg-sub" style="padding: 0 16px 12px">
Select a Deal No and press Retrieve to see its interest schedule.
{{ intCalcExcludedNpi.length }} holding(s) excluded — classified Non-Performing, interest is not posted on
accrual basis (collectible on cash basis only, via Interest Receive in the Investment Interest module).
</p>
}
<div class="pos-section-label">Holdings matching the filter ({{ intCalcTargets.length }})</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script</th>
<th>Type</th>
<th class="num">Rate</th>
<th>Frequency</th>
<th class="num col-in">Interest to Post</th>
</tr>
</thead>
<tbody>
@for (h of intCalcTargets; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName }}</td>
<td>{{ instrumentLabel(h.instrumentType) }}</td>
<td class="num">{{ h.interestRate }}%</td>
<td>{{ h.interestFreq }}</td>
<td class="num col-in">₹{{ interestAccrued(h) | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="pg-empty">No holdings match the selected filters.</td>
</tr>
}
</tbody>
</table>
</div>
}
@@ -6,11 +6,16 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import {
GOVT_SECURITY_HOLDINGS,
NON_SLR_HOLDINGS,
BOND_HOLDINGS,
MUTUAL_FUND_HOLDINGS,
ALL_TXNS,
SCRIPT_MASTER,
PARTY_MASTER,
SUBTYPE_MASTER,
@@ -26,20 +31,16 @@ import {
BOND_RATING_OPTIONS,
INT_TYPE_OPTIONS,
AUTHORITY_OPTIONS,
INVESTMENT_GL_BY_TYPE,
INVESTMENT_GL_LOOKUP,
INTEREST_RECEIVABLE_GL,
SETTLEMENT_BANK_GL_OPTIONS,
periodBetween,
parseDDMMYYYY,
days360,
lastHalfYearlyCouponDate,
INTEREST_INCOME_GL_MASTER,
INTEREST_INCOME_GL_BY_TYPE,
InvestmentHolding,
InvestmentTxn,
InstrumentType,
InterestFreq,
INSTRUMENT_LABELS,
accruedInterest,
accruedInterestBetween,
canAccrueInterest,
daysToMaturity,
maturityAmount,
addPeriod,
@@ -48,13 +49,6 @@ import {
todayDDMMYYYY
} from '../investment-data';
import { exportWorkbook } from '../investment-export';
import {
InvestmentInterestScheduleDto,
InvestmentPurchaseDto,
InvestmentService,
apiErrorMessage,
toHolding
} from '../investment.service';
type BondType = 'GOVT_SECURITY' | 'NON_SLR' | 'BOND' | 'MUTUAL_FUND';
type BondTab =
@@ -137,13 +131,6 @@ interface BondPurchaseForm {
stampDutyAc: string;
}
interface AccountingPreviewLine {
side: 'Dr' | 'Cr';
glCode: string;
glName: string;
amount: number;
}
interface BondSellForm {
/** Legacy screen has SELL / REDEEM / MUTUAL FUND SELL radio at the top. */
mode: 'SELL' | 'REDEEM' | 'MUTUAL_FUND_SELL';
@@ -195,67 +182,23 @@ interface BondSellForm {
DateDdmmyyyyPickerComponent
]
})
export class BondInvestmentComponent implements OnInit {
private readonly investmentService = inject(InvestmentService);
/** Script and party dropdowns offer the masters saved in the backend. */
ngOnInit(): void {
this.investmentService.syncSharedMasters().subscribe({
error: (err) => this.toast(`Could not load Script / Party masters: ${apiErrorMessage(err)}`)
});
this.investmentService.getAssetGlAccounts().subscribe({
next: (gls) =>
(this.glIdByCode = new Map(
gls.map((g) => [
g.glCode,
g.id
])
)),
error: (err) => this.toast(`Could not load GL accounts: ${apiErrorMessage(err)}`)
});
this.loadPurchases();
}
/** GL id for a GL code — the purchase API takes ids, the form works with codes. */
private glIdByCode = new Map<string, number>();
/** The Holdings register and transaction log are the purchases saved in the backend. */
private loadPurchases(): void {
this.investmentService.getPurchases().subscribe({
next: (purchases) => {
this.holdings = purchases.map(toHolding);
this.txns = purchases.map((d) => this.purchaseTxn(d));
this.purchaseForm = this.blankPurchaseForm();
},
error: (err) => this.toast(`Could not load purchases: ${apiErrorMessage(err)}`)
});
}
private purchaseTxn(d: InvestmentPurchaseDto): InvestmentTxn {
return {
certNo: d.certNo,
instrumentType: d.bondType,
txnType: 'PURCHASE',
voucherNo: `INV/PUR/${d.dealNo}`,
txnDate: d.dealDate,
amount: d.settlementAmount,
particulars: `Purchase Deal ${d.dealNo} - ${d.scriptDesc} - ${d.partyName} [${d.category}]`,
glCode: d.investmentGlCode,
glName: d.investmentGlName
};
}
export class BondInvestmentComponent {
activeTab: BondTab = 'holdings';
/** Purchase Entry shows only the mandatory fields; the rest sit under "More Details". */
showMoreDetails = false;
/** Cert No typed in the Retrieve box (Cert No itself is system-generated and read-only). */
retrieveCertNo = '';
bondType: BondType = 'GOVT_SECURITY';
asOnDate = todayDDMMYYYY();
/** Loaded from the backend (saved purchases) in ngOnInit. */
holdings: InvestmentHolding[] = [];
txns: InvestmentTxn[] = [];
holdings: InvestmentHolding[] = [
...GOVT_SECURITY_HOLDINGS,
...NON_SLR_HOLDINGS,
...BOND_HOLDINGS,
...MUTUAL_FUND_HOLDINGS
];
txns: InvestmentTxn[] = ALL_TXNS.filter((t) => ([
'GOVT_SECURITY',
'NON_SLR',
'BOND',
'MUTUAL_FUND'
] as InstrumentType[]).includes(t.instrumentType));
// Master-driven dropdown sources
readonly scriptOptions = SCRIPT_MASTER;
@@ -281,23 +224,23 @@ export class BondInvestmentComponent implements OnInit {
purchaseForm: BondPurchaseForm = this.blankPurchaseForm();
sellForm: BondSellForm = this.blankSellForm();
readonly settlementBankOptions = SETTLEMENT_BANK_GL_OPTIONS;
// Redemption Entry
redeemCertNo = '';
redeemDate = this.asOnDate;
redeemRemarks = '';
// 03 Interest Entry (legacy: Type / Script / Deal No + Retrieve) — the backend works out every
// half-yearly coupon and 31 Mar / 30 Sep closing provision of the holding. Display only:
// nothing is posted to GL until the posting rules are approved.
intType: BondType | '' = '';
intScript = '';
intPurchaseId: number | null = null;
intAsOnDate = this.asOnDate;
intSchedule: InvestmentInterestScheduleDto | null = null;
intLoading = false;
readonly interestReceivableGl = INTEREST_RECEIVABLE_GL;
// Interest Calculation (legacy: From/To Date / Type / Script / Deal No / ALL + Process) —
// posts interest accrued strictly within [intCalcFromDate, intCalcToDate], same period-based
// basis as the Investment Interest module's Govt./Bond Interest Calculation.
intCalcFromDate = addPeriod(this.asOnDate, 0, -3);
intCalcToDate = this.asOnDate;
intCalcType: BondType | '' = '';
intCalcScript = '';
intCalcDealNo = '';
intCalcAll = false;
intCalcGlCode = INTEREST_INCOME_GL_BY_TYPE.GOVT_SECURITY;
readonly interestIncomeGlOptions = INTEREST_INCOME_GL_MASTER;
// Write-off
writeoffCertNo = '';
@@ -362,37 +305,20 @@ export class BondInvestmentComponent implements OnInit {
return INSTRUMENT_LABELS[type];
}
/** Preview of the deal number the backend will assign: FY prefix (e.g. 202627) + running number. */
private nextDealNo(): string {
const d = parseDDMMYYYY(this.asOnDate);
const fyStart = d.getMonth() >= 3 ? d.getFullYear() : d.getFullYear() - 1;
const prefix = `${fyStart}${String((fyStart + 1) % 100).padStart(2, '0')}`;
const nums = (this.holdings ?? [])
.map((h) => h.dealNo)
.filter((n): n is string => !!n && n.startsWith(prefix))
.map((n) => parseInt(n.slice(prefix.length), 10));
return `${prefix}${String((nums.length ? Math.max(...nums) : 0) + 1).padStart(6, '0')}`;
}
/** Certificate No is system-generated, as in the existing CBS (greyed there). */
/** Preview of the certificate number the backend will assign (running number). */
private nextCertNo(): string {
const nums = (this.holdings ?? []).map((h) => parseInt(h.certNo, 10)).filter((n) => !isNaN(n));
return String((nums.length ? Math.max(...nums) : 0) + 1);
return `2026${String(27000039 + this.holdings.length).padStart(9, '0')}`;
}
private blankPurchaseForm(): BondPurchaseForm {
const type = this.bondType ?? 'GOVT_SECURITY';
this.showMoreDetails = false;
this.retrieveCertNo = '';
return {
bondType: type,
dealNo: this.nextDealNo(),
dealDate: this.asOnDate,
dealTime: new Date().toTimeString().slice(0, 8),
dealTime: '',
isin: '',
scriptCode: '',
certNo: this.nextCertNo(),
certNo: '',
settlementType: 'T+0',
settlementDt: this.asOnDate,
faceValue: null,
@@ -400,7 +326,7 @@ export class BondInvestmentComponent implements OnInit {
partyCode: '',
subType: type === 'GOVT_SECURITY' ? 'SLR' : type === 'NON_SLR' ? 'NON SLR' : 'CORPORATE BOND',
tbillGlAc: '',
crToAc: SETTLEMENT_BANK_GL_OPTIONS[0]?.glCode ?? '',
crToAc: '310070',
premiumAmt: null,
brokerage: null,
brokerName: '',
@@ -537,41 +463,13 @@ export class BondInvestmentComponent implements OnInit {
}
/** Accrued Interest at purchase — interest earned by the seller since the last coupon date. */
/** Interest owed to the seller from the last coupon date up to the deal date, on a 30/360 basis —
* interest is counted to the event date, not the date the money moves (Jitendra Sir). */
get purchaseAccruedInterest(): number {
const f = this.purchaseForm;
if (!f.intRate || !f.lastIntDt || !f.dealDate) {
return 0;
}
return Math.round((this.purchaseBaseAmount * (f.intRate / 100) * days360(f.lastIntDt, f.dealDate)) / 360);
}
get purchaseAccruedDays(): number {
const f = this.purchaseForm;
return f.lastIntDt && f.dealDate ? days360(f.lastIntDt, f.dealDate) : 0;
}
/** Premium (+) or discount (−) paid over face value. */
get purchasePremiumDiscount(): number {
const f = this.purchaseForm;
if (this.isMutualFund || !f.faceValue || !f.purchaseRate) {
return 0;
}
return Math.round((f.faceValue * (f.purchaseRate - 100)) / 100);
}
/** Re-suggests the last coupon date when the deal date changes. */
onDealDateChange(): void {
this.suggestLastIntDate();
}
private suggestLastIntDate(): void {
const f = this.purchaseForm;
const s = this.purchaseScript;
if (!this.isMutualFund && s?.maturityDate && f.dealDate) {
f.lastIntDt = lastHalfYearlyCouponDate(s.maturityDate, f.dealDate) || f.lastIntDt;
}
const yrs = Math.max(0, yearsBetween(f.lastIntDt, f.dealDate));
return Math.round(this.purchaseBaseAmount * (f.intRate / 100) * yrs);
}
/** Settlement Amt of Purchase — book value plus accrued interest payable to the seller. */
@@ -606,196 +504,69 @@ export class BondInvestmentComponent implements OnInit {
});
}
get purchaseScript(): (typeof SCRIPT_MASTER)[number] | undefined {
return this.scriptOptions.find((x) => x.scriptCode === this.purchaseForm.scriptCode);
}
/** Picking a script pulls its master details into the deal, as the existing CBS does. */
/** Fills ISIN + interest rate when a script is picked, mirroring the legacy form's lookup. */
onScriptChange(): void {
const s = this.purchaseScript;
if (!s) {
return;
const s = this.scriptOptions.find((x) => x.scriptCode === this.purchaseForm.scriptCode);
if (s) {
this.purchaseForm.isin = s.isin;
}
const f = this.purchaseForm;
f.isin = s.isin;
if (s.couponRate != null) {
f.intRate = s.couponRate;
}
if (s.issueDate) {
f.issueDate = s.issueDate;
}
if (s.maturityDate) {
f.redumMatDt = s.maturityDate;
const p = periodBetween(s.issueDate, s.maturityDate);
f.periodYear = p.years;
f.periodMonth = p.months;
f.periodDays = p.days;
}
if (s.slrType) {
f.subType = s.slrType === 'NON_SLR' ? 'NON SLR' : 'SLR';
}
if (this.isMutualFund) {
f.productName = s.scriptName;
}
this.suggestLastIntDate();
}
/** Settlement Date = Deal Date + T+n days (greyed / auto in the existing CBS). */
get purchaseSettlementDate(): string {
const f = this.purchaseForm;
if (!f.dealDate) {
return '';
}
const n = parseInt(f.settlementType.replace('T+', ''), 10) || 0;
return addPeriod(f.dealDate, 0, 0, n);
}
/** Parties offered for a new deal — inactive parties are hidden. */
get activePartyOptions(): typeof PARTY_MASTER {
return this.partyOptions.filter((p) => p.status !== 'INACTIVE');
}
get purchaseInvestmentGlCode(): string {
return this.purchaseScript?.glCode || INVESTMENT_GL_BY_TYPE[this.purchaseForm.bondType];
}
glNameOf(glCode: string): string {
return INVESTMENT_GL_LOOKUP.find((g) => g.glCode === glCode)?.glName ?? '';
}
/** The journal entry this purchase will post once the checker approves it. */
get purchaseAccountingPreview(): AccountingPreviewLine[] {
const book = this.purchaseBookValue;
const accrued = this.purchaseAccruedInterest;
const total = this.purchaseSettlementAmount;
const crGl = this.purchaseForm.crToAc;
if (!book || !crGl) {
return [];
}
const invGl = this.purchaseInvestmentGlCode;
const lines: AccountingPreviewLine[] = [
{ side: 'Dr', glCode: invGl, glName: this.glNameOf(invGl), amount: book }];
if (accrued > 0) {
lines.push({
side: 'Dr',
glCode: INTEREST_RECEIVABLE_GL.glCode,
glName: INTEREST_RECEIVABLE_GL.glName,
amount: accrued
});
}
lines.push({ side: 'Cr', glCode: crGl, glName: this.glNameOf(crGl), amount: total });
return lines;
}
savePurchase(): void {
const f = this.purchaseForm;
const missing: string[] = [];
if (!f.scriptCode) missing.push('Script Code');
if (!f.partyCode) missing.push('Party Code');
if (!f.dealDate) missing.push('Deal Date');
if (this.isMutualFund) {
if (!f.considerAmt) missing.push('Consider Amt');
if (!f.nav) missing.push('NAV');
} else {
if (!f.faceValue) missing.push('Face Value');
if (!f.purchaseRate) missing.push('Purchase Rate');
if (!f.lastIntDt) missing.push('Last Interest Date');
}
if (!f.category) missing.push('Category');
if (!f.crToAc) missing.push('CR. To A/c');
if (missing.length) {
this.toast(`Please fill: ${missing.join(', ')}.`);
if (!f.certNo || !this.purchaseBaseAmount || !f.partyCode) {
this.toast('Please fill Cert No, Face Value / Consider Amt and Party Code.');
return;
}
const script = this.purchaseScript;
const party = this.partyOptions.find((p) => p.partyCode === f.partyCode);
const crGlAccountId = this.glIdByCode.get(f.crToAc);
if (!script?.id || !party?.id) {
this.toast('Script and Party must be saved in Investment Masters first.');
return;
}
if (!crGlAccountId) {
this.toast(`GL ${f.crToAc} was not found in the chart of accounts.`);
return;
}
const body = {
bondType: f.bondType,
scriptId: script.id,
partyId: party.id,
dealDate: f.dealDate,
dealTime: f.dealTime || '',
settlementType: f.settlementType,
...(this.isMutualFund
? { considerAmt: f.considerAmt, nav: f.nav, stampDuty: f.stampDuty ?? null }
: { faceValue: f.faceValue, purchaseRate: f.purchaseRate, lastIntDate: f.lastIntDt }),
brokerage: f.brokerage ?? null,
category: f.category,
crGlAccountId,
subType: f.subType || '',
otherDetails: this.purchaseOtherDetails(),
dateFormat: 'dd-MM-yyyy',
locale: 'en'
};
this.investmentService.createPurchase(body).subscribe({
next: (res) => {
this.toast(`Deal ${res.resourceIdentifier} saved. GL posting will follow once accounting is approved.`);
this.activeTab = 'holdings';
this.loadPurchases();
},
error: (err) => this.toast(apiErrorMessage(err))
});
}
/** The optional "More Details" fields, kept with the deal as they were entered. */
private purchaseOtherDetails(): Record<string, string | number> {
const f = this.purchaseForm;
const fields: Record<string, string | number | null> = {
tradeNo: f.tradeNo,
orderNo: f.orderNo,
const holding: InvestmentHolding = {
certNo: f.certNo,
instrumentType: f.bondType,
scriptCode: f.scriptCode || f.isin || f.productName,
scriptName: this.isMutualFund
? f.productName
: this.scriptOptions.find((s) => s.scriptCode === f.scriptCode)?.scriptName || f.scriptCode || f.isin,
party: f.partyCode,
subType: f.subType,
faceValue: this.purchaseBaseAmount,
purchaseRate: this.isMutualFund ? f.nav || 0 : f.purchaseRate || 100,
units: this.purchaseUnits,
purchaseDate: f.issueDate,
maturityDate: this.purchaseMaturityDate || f.issueDate,
interestRate: f.intRate || 0,
interestFreq: this.isMutualFund ? 'CUMULATIVE' : f.intType,
glCode:
'31' +
(f.bondType === 'GOVT_SECURITY'
? '0020'
: f.bondType === 'NON_SLR'
? '0030'
: f.bondType === 'BOND'
? '0040'
: '0050'),
glName: `INVESTMENT IN ${INSTRUMENT_LABELS[f.bondType].toUpperCase()}`,
crToAc: f.crToAc,
brokerCode: f.brokerName,
recommAuthority: f.recommAuthority,
sanctionAuthority: f.sanctionAuthority,
meetDate: f.meetDt,
resoNo: f.resoNo,
dealType: f.dealType,
market: f.market,
markCblo: f.markCblo,
paymentMode: f.paymentMode,
deliveryMode: f.deliveryMode,
lien: f.lien,
lienAmount: f.lienAmount,
lienReason: f.lienReason,
bondRating: f.bondRating,
corpBond: f.corpBond,
sglWithBank: f.sglWithBank,
tbillGlAc: f.tbillGlAc,
intType: f.intType,
intDate: f.intDate,
folioNo: f.folioNo,
callOption: f.callOption,
descInv: f.descInv,
fromDist: f.fromDist,
toDist: f.toDist,
amortizeAmt: f.amortizeAmt,
registerAddress: f.registerAdd,
registerCity: f.regiCity,
registerPin: f.regiPin,
remarks: f.remarks,
description: f.description,
tradeAmt: f.tradeAmt,
holdingDays: f.holdingDays,
stampDutyAc: f.stampDutyAc
status: 'ACTIVE'
};
const out: Record<string, string | number> = {};
for (const [
k,
v
] of Object.entries(fields)) {
if (v !== null && v !== undefined && v !== '') {
out[k] = v;
}
}
return out;
this.holdings = [
holding,
...this.holdings
];
this.txns = [
{
certNo: holding.certNo,
instrumentType: f.bondType,
txnType: 'PURCHASE',
voucherNo: `INV/PUR/${f.dealNo}`,
txnDate: f.dealDate,
amount: this.purchaseSettlementAmount,
particulars: `Purchase Deal ${f.dealNo} - ${holding.scriptName} - ${holding.party}${f.category ? ' [' + f.category + ']' : ''}`
},
...this.txns
];
this.toast(`${INSTRUMENT_LABELS[f.bondType]} Certificate ${holding.certNo} saved successfully.`);
this.purchaseForm = this.blankPurchaseForm();
this.activeTab = 'holdings';
}
cancelPurchase(): void {
@@ -805,10 +576,9 @@ export class BondInvestmentComponent implements OnInit {
/** Legacy "Retrieve" button — loads an existing deal back into the form by Cert No. */
retrievePurchase(): void {
const certNo = this.retrieveCertNo.trim();
const h = this.holdings.find((x) => x.certNo === certNo);
const h = this.holdings.find((x) => x.certNo === this.purchaseForm.certNo.trim());
if (!h) {
this.toast(`Cert No ${certNo || '(blank)'} not found.`);
this.toast(`Cert No ${this.purchaseForm.certNo} not found.`);
return;
}
this.purchaseForm = {
@@ -817,8 +587,7 @@ export class BondInvestmentComponent implements OnInit {
certNo: h.certNo,
scriptCode: h.scriptCode,
isin: this.scriptOptions.find((s) => s.scriptCode === h.scriptCode)?.isin || '',
dealNo: h.dealNo ?? '',
partyCode: h.partyCode ?? '',
partyCode: h.party,
subType: h.subType,
faceValue: h.faceValue,
considerAmt: h.faceValue,
@@ -830,26 +599,22 @@ export class BondInvestmentComponent implements OnInit {
intType: h.interestFreq,
brokerName: h.brokerCode,
crToAc: h.crToAc,
productName: h.scriptName,
category: h.category ?? ''
productName: h.scriptName
};
this.bondType = h.instrumentType as BondType;
this.toast(`Deal for Cert No ${h.certNo} retrieved.`);
}
dropPurchase(): void {
const h = this.holdings.find((x) => x.certNo === this.purchaseForm.certNo);
if (!h?.id) {
this.toast('Retrieve a saved deal first, then Drop.');
const certNo = this.purchaseForm.certNo;
if (!this.holdings.some((h) => h.certNo === certNo)) {
this.toast('Nothing to drop — this deal is not yet saved.');
return;
}
this.investmentService.deletePurchase(h.id).subscribe({
next: () => {
this.toast(`Deal ${h.dealNo} (Cert No ${h.certNo}) dropped.`);
this.loadPurchases();
},
error: (err) => this.toast(apiErrorMessage(err))
});
this.holdings = this.holdings.filter((h) => h.certNo !== certNo);
this.txns = this.txns.filter((t) => t.certNo !== certNo);
this.purchaseForm = this.blankPurchaseForm();
this.toast(`Cert No ${certNo} dropped.`);
}
exitPurchase(): void {
@@ -893,53 +658,78 @@ export class BondInvestmentComponent implements OnInit {
}
// -------------------- Interest calculation --------------------
/** Saved holdings for the Deal No dropdown, narrowed by Type and Script. */
get intDealOptions(): InvestmentHolding[] {
return this.holdings.filter(
(h) =>
h.id != null &&
h.status === 'ACTIVE' &&
(!this.intType || h.instrumentType === this.intType) &&
(!this.intScript || h.scriptCode === this.intScript)
);
}
get intScriptOptions(): typeof SCRIPT_MASTER {
return this.scriptOptions.filter((s) => !this.intType || s.instrumentType === this.intType);
}
onIntFilterChange(): void {
if (this.intScript && !this.intScriptOptions.some((s) => s.scriptCode === this.intScript)) {
this.intScript = '';
/** Holdings the Interest Calculation "Process" run would cover, given the filters. A
* Non-Performing holding is always excluded — same accrual-basis rule as the Investment
* Interest module (it's still collectible on cash basis via Interest Receive there). */
get intCalcTargets(): InvestmentHolding[] {
const base = this.holdings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h));
if (this.intCalcAll) {
return base;
}
if (this.intPurchaseId != null && !this.intDealOptions.some((h) => h.id === this.intPurchaseId)) {
this.intPurchaseId = null;
}
this.intSchedule = null;
}
retrieveInterestSchedule(): void {
if (this.intPurchaseId == null) {
this.toast('Select a Deal No first.');
return;
}
this.intLoading = true;
this.investmentService.getInterestSchedule(this.intPurchaseId, this.intAsOnDate || undefined).subscribe({
next: (schedule) => {
this.intSchedule = schedule;
this.intLoading = false;
},
error: (err) => {
this.intSchedule = null;
this.intLoading = false;
this.toast(apiErrorMessage(err));
}
return base.filter((h) => {
if (this.intCalcType && h.instrumentType !== this.intCalcType) return false;
if (this.intCalcScript && h.scriptCode !== this.intCalcScript) return false;
if (this.intCalcDealNo && h.certNo !== this.intCalcDealNo.trim()) return false;
return true;
});
}
/** Total of the lines falling due up to the as-on date. */
get intDueTotal(): number {
return (this.intSchedule?.rows ?? []).filter((r) => r.due).reduce((sum, r) => sum + r.amount, 0);
get intCalcExcludedNpi(): InvestmentHolding[] {
return this.holdings.filter((h) => h.status === 'ACTIVE' && !canAccrueInterest(h));
}
/** Re-suggests the Interest Income GL when the Type filter changes — still overridable. */
onIntCalcTypeChange(): void {
if (this.intCalcType) {
this.intCalcGlCode = INTEREST_INCOME_GL_BY_TYPE[this.intCalcType];
}
}
glName(glCode: string): string {
return this.interestIncomeGlOptions.find((g) => g.glCode === glCode)?.glName ?? glCode;
}
/** Interest accrued strictly within [intCalcFromDate, intCalcToDate] — a period-based
* Interest Calculation & Post run, same basis as the Investment Interest module. */
interestAccrued(h: InvestmentHolding): number {
return accruedInterestBetween(h, this.intCalcFromDate || h.purchaseDate, this.intCalcToDate || this.asOnDate);
}
processInterest(): void {
const targets = this.intCalcTargets.filter((h) => this.interestAccrued(h) > 0);
if (targets.length === 0) {
this.toast('No holdings have interest due to be posted for this period.');
return;
}
const toDate = this.intCalcToDate || this.asOnDate;
const fromDate = this.intCalcFromDate || toDate;
const glCode = this.intCalcGlCode;
const glName = this.glName(glCode);
const newTxns: InvestmentTxn[] = targets.map((h, i) => {
const amt = this.interestAccrued(h);
h.interestPosted = (h.interestPosted ?? 0) + amt;
h.interestPostedTillDate = toDate;
return {
certNo: h.certNo,
instrumentType: h.instrumentType,
txnType: 'INTEREST',
voucherNo: `INV/INT/${h.certNo}-${this.txns.length + i + 1}`,
txnDate: toDate,
amount: amt,
particulars: `Interest calculated (accrual) for period ${fromDate} to ${toDate} - ${h.scriptName}`,
glCode,
glName
};
});
this.txns = [
...newTxns,
...this.txns
];
this.intCalcFromDate = toDate;
const total = newTxns.reduce((s, t) => s + t.amount, 0);
this.toast(
`Interest processed for ${targets.length} holding(s) to ${glCode} - ${glName}. Total ₹${total.toLocaleString('en-IN')}.`
);
}
// -------------------- Sell / Redeem Entry --------------------
+24 -426
View File
@@ -123,16 +123,6 @@ export interface InvestmentHolding {
* never re-counts days up to this date, so re-running a Calculation & Post for an overlapping
* or repeated period always yields 0 instead of double-posting the same interest. */
interestPostedTillDate?: string;
/** RBI investment category chosen at purchase — HTM / AFS / HFT. */
category?: string;
/** SGL / CSGL account the security is held in (custody, not an accounting GL). */
sglAccountNo?: string;
/** Backend id / deal number / party code once the purchase is saved (absent on sample rows). */
id?: number;
dealNo?: string;
partyCode?: string;
bookValue?: number;
settlementAmount?: number;
}
export interface InvestmentTxn {
@@ -258,51 +248,6 @@ export function accruedInterestBetween(h: InvestmentHolding, fromStr: string, to
return Math.round(h.faceValue * (h.interestRate / 100) * (days / 365.25));
}
/** Days between two DD-MM-YYYY dates on a 30/360 basis (every month 30 days), as the bank's existing
* CBS counts them — e.g. 10-08 → 30-09 = 50 days, 10-08 → 07-10 = 57 days. */
export function days360(fromStr: string, toStr: string): number {
const a = parseDDMMYYYY(fromStr);
const b = parseDDMMYYYY(toStr);
if (isNaN(a.getTime()) || isNaN(b.getTime()) || b <= a) {
return 0;
}
const d1 = Math.min(a.getDate(), 30);
const d2 = Math.min(b.getDate(), 30);
return (b.getFullYear() - a.getFullYear()) * 360 + (b.getMonth() - a.getMonth()) * 30 + (d2 - d1);
}
/** Most recent half-yearly coupon date on or before `onStr`, derived from the maturity date (coupons
* fall on the maturity day/month and six months either side of it). */
export function lastHalfYearlyCouponDate(maturityStr: string, onStr: string): string {
const mat = parseDDMMYYYY(maturityStr);
const on = parseDDMMYYYY(onStr);
if (isNaN(mat.getTime()) || isNaN(on.getTime())) {
return '';
}
const day = mat.getDate();
const couponOn = (year: number, month: number): Date => {
const lastDay = new Date(year, month + 1, 0).getDate();
return new Date(year, month, Math.min(day, lastDay));
};
const months = [
mat.getMonth() % 6,
(mat.getMonth() % 6) + 6
];
let best: Date | undefined;
for (const year of [
on.getFullYear() - 1,
on.getFullYear()
]) {
for (const m of months) {
const c = couponOn(year, m);
if (c <= on && (!best || c > best)) {
best = c;
}
}
}
return best ? formatDDMMYYYY(best) : '';
}
export function formatDDMMYYYY(d: Date): string {
return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
}
@@ -1459,319 +1404,99 @@ export const CASH_BANK_GL_MASTER: InvestmentGlOption[] = [
{ glCode: '110030', glName: 'HDFC BANK CURRENT ACCOUNT' }
];
/** Investment GLs a script / party can be mapped to. PROPOSED codes awaiting approval — the
* backend will take these from the chart of accounts, never hard-coded. */
export const INVESTMENT_GL_OPTIONS: InvestmentGlOption[] = [
{ glCode: '100701', glName: 'INVESTMENT IN CENTRAL GOVT SECURITIES' },
{ glCode: '100702', glName: 'INVESTMENT IN STATE GOVT SECURITIES' },
{ glCode: '100703', glName: 'INVESTMENT IN BONDS / DEBENTURES' },
{ glCode: '100704', glName: 'FIXED DEPOSITS WITH OTHER BANKS' },
{ glCode: '100705', glName: 'INVESTMENT IN MUTUAL FUNDS' },
{ glCode: '100706', glName: 'INVESTMENT IN SHARES' }
];
/** Fallback investment GL when a script has none mapped. */
export const INVESTMENT_GL_BY_TYPE: Record<InstrumentType, string> = {
GOVT_SECURITY: '100701',
NON_SLR: '100703',
BOND: '100703',
FD: '100704',
MUTUAL_FUND: '100705',
SHARE: '100706'
};
/** Interest receivable (the senior's "Provision A/c") — debited for interest bought from the seller. */
export const INTEREST_RECEIVABLE_GL: InvestmentGlOption = {
glCode: '100603',
glName: 'INTEREST RECEIVABLE ON INVESTMENTS (PROVISION)'
};
/** Bank accounts a purchase can be paid from — these GLs already exist in the chart of accounts. */
export const SETTLEMENT_BANK_GL_OPTIONS: InvestmentGlOption[] = [
{ glCode: '100201', glName: 'IDBI BANK' },
{ glCode: '100202', glName: 'YES BANK' }
];
/** Every GL the Investment screens can show by name. */
export const INVESTMENT_GL_LOOKUP: InvestmentGlOption[] = [
...INVESTMENT_GL_OPTIONS,
INTEREST_RECEIVABLE_GL,
...SETTLEMENT_BANK_GL_OPTIONS
];
/** SLR classification on the Script Master, as in the bank's existing CBS ("GOI" = Central Govt). */
export type SlrType = 'GOI' | 'STATE_GOVT' | 'TREASURY' | 'NON_SLR';
export const SLR_TYPE_OPTIONS: Array<{ value: SlrType; label: string }> = [
{ value: 'GOI', label: 'GOI' },
{ value: 'STATE_GOVT', label: 'STATE GOVT' },
{ value: 'TREASURY', label: 'TREASURY' },
{ value: 'NON_SLR', label: 'NON SLR' }
];
export interface ScriptMaster {
/** Backend id once the script is saved (absent on the sample rows). */
id?: number;
scriptCode: string;
scriptName: string;
instrumentType: InstrumentType;
isin: string;
faceValuePerUnit: number;
// Fields below follow the bank's existing CBS Script Master; a purchase picks them up instead
// of the dealer re-typing them on every deal.
slrType?: SlrType;
/** Investment GL this script is booked to. */
glAccountId?: number;
glCode?: string;
glName?: string;
active?: boolean;
/** Interest (coupon) rate % per annum. */
couponRate?: number;
issueDate?: string;
maturityDate?: string;
// Issuer details — used for Bond / Mutual Fund scripts.
companyName?: string;
address1?: string;
address2?: string;
address3?: string;
city?: string;
pincode?: string;
phone?: string;
fax?: string;
corpCode?: string;
}
/** Period between two DD-MM-YYYY dates as years / months / days, as shown on the Script Master. */
export function periodBetween(fromStr?: string, toStr?: string): { years: number; months: number; days: number } {
const none = { years: 0, months: 0, days: 0 };
if (!fromStr || !toStr) {
return none;
}
const from = parseDDMMYYYY(fromStr);
const to = parseDDMMYYYY(toStr);
if (isNaN(from.getTime()) || isNaN(to.getTime()) || to < from) {
return none;
}
let years = to.getFullYear() - from.getFullYear();
let months = to.getMonth() - from.getMonth();
let days = to.getDate() - from.getDate();
if (days < 0) {
months -= 1;
days += new Date(to.getFullYear(), to.getMonth(), 0).getDate();
}
if (months < 0) {
years -= 1;
months += 12;
}
return { years, months, days };
}
export const SCRIPT_MASTER: ScriptMaster[] = [
{
scriptCode: 'FD-CUM',
scriptName: 'Cumulative Fixed Deposit',
instrumentType: 'FD',
isin: '',
faceValuePerUnit: 1,
slrType: 'NON_SLR',
glCode: '100704'
},
{ scriptCode: 'FD-CUM', scriptName: 'Cumulative Fixed Deposit', instrumentType: 'FD', isin: '', faceValuePerUnit: 1 },
{
scriptCode: 'FD-MIP',
scriptName: 'Monthly Interest Payout FD',
instrumentType: 'FD',
isin: '',
faceValuePerUnit: 1,
slrType: 'NON_SLR',
glCode: '100704'
faceValuePerUnit: 1
},
{
scriptCode: 'IN0020230012',
scriptName: '7.26% GOI 2033',
instrumentType: 'GOVT_SECURITY',
isin: 'IN0020230012',
faceValuePerUnit: 100,
slrType: 'GOI',
glCode: '100701',
couponRate: 7.26,
issueDate: '15-01-2023',
maturityDate: '15-01-2033'
faceValuePerUnit: 100
},
{
scriptCode: 'IN0020220089',
scriptName: '7.10% GOI 2029',
instrumentType: 'GOVT_SECURITY',
isin: 'IN0020220089',
faceValuePerUnit: 100,
slrType: 'GOI',
glCode: '100701',
couponRate: 7.1,
issueDate: '10-03-2022',
maturityDate: '10-03-2029'
},
{
scriptCode: 'IN0020210056',
scriptName: '6.79% GOI 2027',
instrumentType: 'GOVT_SECURITY',
isin: 'IN0020210056',
faceValuePerUnit: 100,
slrType: 'GOI',
glCode: '100701',
couponRate: 6.79,
issueDate: '05-06-2021',
maturityDate: '05-06-2027'
},
{
scriptCode: 'IN0020240034',
scriptName: '7.18% GOI 2037',
instrumentType: 'GOVT_SECURITY',
isin: 'IN0020240034',
faceValuePerUnit: 100,
slrType: 'GOI',
glCode: '100701',
couponRate: 7.18,
issueDate: '22-04-2024',
maturityDate: '22-04-2037'
},
{
scriptCode: 'MH-SDL-2031',
scriptName: '8.05% Maharashtra SDL 2031',
instrumentType: 'GOVT_SECURITY',
isin: '',
faceValuePerUnit: 100,
slrType: 'STATE_GOVT',
glCode: '100702',
couponRate: 8.05,
issueDate: '15-02-2021',
maturityDate: '15-02-2031'
},
{
scriptCode: 'GJ-SDL-2026',
scriptName: '7.85% Gujarat SDL 2026',
instrumentType: 'GOVT_SECURITY',
isin: '',
faceValuePerUnit: 100,
slrType: 'STATE_GOVT',
glCode: '100702',
couponRate: 7.85,
issueDate: '18-09-2016',
maturityDate: '18-09-2026'
faceValuePerUnit: 100
},
{
scriptCode: 'TBILL-364D',
scriptName: '364 Days Treasury Bill',
instrumentType: 'GOVT_SECURITY',
isin: '',
faceValuePerUnit: 100,
slrType: 'TREASURY',
glCode: '100701',
couponRate: 0,
issueDate: '02-07-2026',
maturityDate: '02-07-2027'
},
{
scriptCode: 'TBILL-91D',
scriptName: '91 Days Treasury Bill',
instrumentType: 'GOVT_SECURITY',
isin: '',
faceValuePerUnit: 100,
slrType: 'TREASURY',
glCode: '100701',
couponRate: 0,
issueDate: '15-05-2026',
maturityDate: '13-08-2026'
faceValuePerUnit: 100
},
{
scriptCode: 'REC-7.5-2031',
scriptName: 'REC Ltd Bond 7.50% 2031',
instrumentType: 'NON_SLR',
isin: 'INE020B08JN0',
faceValuePerUnit: 100,
slrType: 'NON_SLR',
glCode: '100703',
couponRate: 7.5,
issueDate: '11-08-2021',
maturityDate: '11-08-2031',
companyName: 'REC Limited',
city: 'New Delhi'
faceValuePerUnit: 100
},
{
scriptCode: 'NABARD-7.35-2030',
scriptName: 'NABARD Bond 7.35% 2030',
instrumentType: 'NON_SLR',
isin: 'INE261F09EU1',
faceValuePerUnit: 100,
slrType: 'NON_SLR',
glCode: '100703',
couponRate: 7.35,
issueDate: '20-09-2020',
maturityDate: '20-09-2030',
companyName: 'NABARD',
city: 'Mumbai'
faceValuePerUnit: 100
},
{
scriptCode: 'LTF-8.1-2028',
scriptName: 'L&T Finance NCD 8.10% 2028',
instrumentType: 'BOND',
isin: 'INE027E07BR5',
faceValuePerUnit: 100,
slrType: 'NON_SLR',
glCode: '100703',
couponRate: 8.1,
companyName: 'L&T Finance Ltd',
city: 'Mumbai'
faceValuePerUnit: 100
},
{
scriptCode: 'BAF-7.95-2027',
scriptName: 'Bajaj Finance NCD 7.95% 2027',
instrumentType: 'BOND',
isin: 'INE296A07SP0',
faceValuePerUnit: 100,
slrType: 'NON_SLR',
glCode: '100703',
couponRate: 7.95,
companyName: 'Bajaj Finance Ltd',
city: 'Pune'
faceValuePerUnit: 100
},
{
scriptCode: 'SBI-OVN',
scriptName: 'SBI Overnight Fund',
instrumentType: 'MUTUAL_FUND',
isin: 'INF200KA1Q40',
faceValuePerUnit: 1000,
slrType: 'NON_SLR',
glCode: '100705',
companyName: 'SBI Mutual Fund',
city: 'Mumbai'
faceValuePerUnit: 1000
},
{
scriptCode: 'HDFC-LIQ',
scriptName: 'HDFC Liquid Fund',
instrumentType: 'MUTUAL_FUND',
isin: 'INF179KA1WR9',
faceValuePerUnit: 1000,
slrType: 'NON_SLR',
glCode: '100705',
companyName: 'HDFC Mutual Fund',
city: 'Mumbai'
faceValuePerUnit: 1000
},
{
scriptCode: 'SBIN',
scriptName: 'State Bank of India',
instrumentType: 'SHARE',
isin: 'INE062A01020',
faceValuePerUnit: 1,
slrType: 'NON_SLR',
glCode: '100706'
faceValuePerUnit: 1
},
{
scriptCode: 'HDFCBANK',
scriptName: 'HDFC Bank Ltd',
instrumentType: 'SHARE',
isin: 'INE040A01034',
faceValuePerUnit: 1,
slrType: 'NON_SLR',
glCode: '100706'
faceValuePerUnit: 1
}
];
@@ -1836,150 +1561,23 @@ export const BROKER_MASTER: BrokerMaster[] = [
];
export interface PartyMaster {
/** Backend id once the party is saved (absent on the sample rows). */
id?: number;
partyCode: string;
partyName: string;
partyType: 'BANK' | 'CORPORATE' | 'GOVT' | 'AMC' | 'EXCHANGE' | 'COOP_SOCIETY';
panNo?: string;
// Fields below follow the bank's existing CBS Bank / Party Master.
bondType?: InstrumentType;
address1?: string;
address2?: string;
address3?: string;
city?: string;
pincode?: string;
phone?: string;
fax?: string;
bankType?: string;
/** GL this party is mapped to. */
glAccountId?: number;
glCode?: string;
glName?: string;
rbiCode?: string;
/** Exposure limit for this party (₹). */
limit?: number | null;
leiNo?: string;
// Counterparty's bank details
cpBankName?: string;
cpBranchName?: string;
cpAccountNo?: string;
cpIfsc?: string;
/** Absent means ACTIVE. An INACTIVE party is not offered for new deals. */
status?: 'ACTIVE' | 'INACTIVE';
panNo: string;
}
/** Sample Bank Type options until the bank confirms its list. */
export const BANK_TYPE_OPTIONS = [
'SCHEDULED COMMERCIAL BANK',
'STATE CO-OPERATIVE BANK',
'DISTRICT CENTRAL CO-OPERATIVE BANK',
'URBAN CO-OPERATIVE BANK',
'RBI / GOVERNMENT',
'OTHER'
];
export const PARTY_MASTER: PartyMaster[] = [
{
partyCode: 'PTY-001',
partyName: 'Jila Sahakari Kendriya Bank',
partyType: 'BANK',
bondType: 'FD',
glCode: '100704',
city: 'Vidisha',
bankType: 'DISTRICT CENTRAL CO-OPERATIVE BANK',
status: 'ACTIVE'
},
{
partyCode: 'PTY-002',
partyName: 'State Cooperative Bank Ltd',
partyType: 'BANK',
bondType: 'FD',
glCode: '100704',
city: 'Bhopal',
bankType: 'STATE CO-OPERATIVE BANK',
status: 'ACTIVE'
},
{
partyCode: 'PTY-003',
partyName: 'RBI - SGL',
partyType: 'GOVT',
bondType: 'GOVT_SECURITY',
glCode: '100701',
city: 'Mumbai',
bankType: 'RBI / GOVERNMENT',
status: 'ACTIVE'
},
{
partyCode: 'PTY-004',
partyName: 'REC Limited',
partyType: 'CORPORATE',
bondType: 'NON_SLR',
glCode: '100703',
city: 'New Delhi',
bankType: 'OTHER',
status: 'ACTIVE'
},
{
partyCode: 'PTY-005',
partyName: 'NABARD',
partyType: 'CORPORATE',
bondType: 'NON_SLR',
glCode: '100703',
city: 'Mumbai',
bankType: 'OTHER',
status: 'ACTIVE'
},
{
partyCode: 'PTY-006',
partyName: 'L&T Finance Ltd',
partyType: 'CORPORATE',
bondType: 'BOND',
glCode: '100703',
city: 'Mumbai',
bankType: 'OTHER',
status: 'ACTIVE'
},
{
partyCode: 'PTY-007',
partyName: 'SBI Mutual Fund',
partyType: 'AMC',
bondType: 'MUTUAL_FUND',
glCode: '100705',
city: 'Mumbai',
bankType: 'OTHER',
status: 'ACTIVE'
},
{
partyCode: 'PTY-008',
partyName: 'HDFC Mutual Fund',
partyType: 'AMC',
bondType: 'MUTUAL_FUND',
glCode: '100705',
city: 'Mumbai',
bankType: 'OTHER',
status: 'ACTIVE'
},
{
partyCode: 'PTY-009',
partyName: 'NSE/BSE',
partyType: 'EXCHANGE',
bondType: 'SHARE',
glCode: '100706',
city: 'Mumbai',
bankType: 'OTHER',
status: 'ACTIVE'
},
{
partyCode: 'PTY-010',
partyName: 'District Cooperative Bank',
partyType: 'COOP_SOCIETY',
bondType: 'SHARE',
glCode: '100706',
city: 'Vidisha',
bankType: 'DISTRICT CENTRAL CO-OPERATIVE BANK',
status: 'ACTIVE'
}
{ partyCode: 'PTY-001', partyName: 'Jila Sahakari Kendriya Bank', partyType: 'BANK', panNo: 'AABCJ1234K' },
{ partyCode: 'PTY-002', partyName: 'State Cooperative Bank Ltd', partyType: 'BANK', panNo: 'AABCS5678L' },
{ partyCode: 'PTY-003', partyName: 'RBI - SGL', partyType: 'GOVT', panNo: 'AAACR4849R' },
{ partyCode: 'PTY-004', partyName: 'REC Limited', partyType: 'CORPORATE', panNo: 'AAACR0641G' },
{ partyCode: 'PTY-005', partyName: 'NABARD', partyType: 'CORPORATE', panNo: 'AAACN0272B' },
{ partyCode: 'PTY-006', partyName: 'L&T Finance Ltd', partyType: 'CORPORATE', panNo: 'AAACL0140P' },
{ partyCode: 'PTY-007', partyName: 'SBI Mutual Fund', partyType: 'AMC', panNo: 'AAATS4325L' },
{ partyCode: 'PTY-008', partyName: 'HDFC Mutual Fund', partyType: 'AMC', panNo: 'AAATH1234M' },
{ partyCode: 'PTY-009', partyName: 'NSE/BSE', partyType: 'EXCHANGE', panNo: 'AAACN0028G' },
{ partyCode: 'PTY-010', partyName: 'District Cooperative Bank', partyType: 'COOP_SOCIETY', panNo: 'AABCD9988K' }
];
export interface SubTypeMaster {
@@ -106,112 +106,38 @@
</button>
</div>
<!-- PARTY MASTER (layout follows the bank's existing CBS Bank / Party Master) -->
<!-- PARTY MASTER -->
@if (activeTab === 'party') {
<div class="pg-card">
<div class="pos-section-label">Bank / Party Master — Entry</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>* Bond Type</span>
<select [(ngModel)]="partyForm.bondType">
<option [ngValue]="undefined">-- Select --</option>
@for (t of instrumentTypes; track t) {
<option [value]="t">{{ t }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>* Party Code</span><input type="text" [value]="partyForm.partyCode || nextPartyCode" readonly
/></label>
<label class="inv-form-field"
><span>* Party Name</span><input type="text" [(ngModel)]="partyForm.partyName"
/></label>
<label class="inv-form-field"
><span>Address Line 1 (Building / Flat No.)</span
><input type="text" placeholder="e.g. Shop No. 12, Shanti Complex" [(ngModel)]="partyForm.address1"
/></label>
<label class="inv-form-field"
><span>Address Line 2 (Street / Area)</span
><input type="text" placeholder="e.g. MG Road, Near Bus Stand" [(ngModel)]="partyForm.address2"
/></label>
<label class="inv-form-field"
><span>Address Line 3 (Locality / Landmark)</span
><input type="text" placeholder="e.g. Arera Colony" [(ngModel)]="partyForm.address3"
/></label>
<label class="inv-form-field"><span>City</span><input type="text" [(ngModel)]="partyForm.city" /></label>
<label class="inv-form-field"
><span>Pincode</span><input type="text" maxlength="6" [(ngModel)]="partyForm.pincode"
/></label>
<label class="inv-form-field"><span>Phone</span><input type="text" [(ngModel)]="partyForm.phone" /></label>
<label class="inv-form-field"><span>Fax</span><input type="text" [(ngModel)]="partyForm.fax" /></label>
<label class="inv-form-field">
<span>Bank Type</span>
<select [(ngModel)]="partyForm.bankType">
<option [ngValue]="undefined">-- Select --</option>
@for (b of bankTypeOptions; track b) {
<option [value]="b">{{ b }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>* GL A/c No.</span>
<select [(ngModel)]="partyForm.glAccountId">
<option [ngValue]="undefined">-- Select --</option>
@for (g of investmentGlOptions; track g.id) {
<option [ngValue]="g.id">{{ g.glCode }} — {{ g.name }}</option>
}
</select>
</label>
<label class="inv-form-field"><span>RBI Code</span><input type="text" [(ngModel)]="partyForm.rbiCode" /></label>
<label class="inv-form-field"
><span>Limit (₹)</span><input type="number" min="0" [(ngModel)]="partyForm.limit"
/></label>
<label class="inv-form-field"><span>LEI No</span><input type="text" [(ngModel)]="partyForm.leiNo" /></label>
<label class="inv-form-field">
<span>Status</span>
<select [(ngModel)]="partyForm.status">
<option [ngValue]="undefined">ACTIVE</option>
<option value="INACTIVE">INACTIVE</option>
</select>
</label>
</div>
<div class="pos-section-label">Counterparty's Bank Details</div>
<div class="pos-section-label">Add Bank / Party</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Name of Bank</span><input type="text" [(ngModel)]="partyForm.cpBankName"
/></label>
<label class="inv-form-field"
><span>Name of Branch</span><input type="text" [(ngModel)]="partyForm.cpBranchName"
/></label>
<label class="inv-form-field"
><span>Account No.</span><input type="text" [(ngModel)]="partyForm.cpAccountNo"
/></label>
<label class="inv-form-field"
><span>IFSC</span><input type="text" maxlength="11" [(ngModel)]="partyForm.cpIfsc"
><span>Party Name *</span><input type="text" [(ngModel)]="newParty.partyName"
/></label>
<label class="inv-form-field">
<span>Party Type *</span>
<select [(ngModel)]="newParty.partyType">
<option [ngValue]="undefined">-- Select --</option>
<option value="BANK">BANK</option>
<option value="CORPORATE">CORPORATE</option>
<option value="GOVT">GOVT</option>
<option value="AMC">AMC</option>
<option value="EXCHANGE">EXCHANGE</option>
<option value="COOP_SOCIETY">COOP SOCIETY</option>
</select>
</label>
<label class="inv-form-field"><span>PAN No</span><input type="text" [(ngModel)]="newParty.panNo" /></label>
</div>
<div class="inv-form-actions">
<label class="inv-form-field"
><span>Search Party Code</span><input type="text" placeholder="e.g. PTY-001" [(ngModel)]="partySearchCode"
/></label>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="saving" (click)="saveParty()">Save</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="searchParty()">Search</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelParty()">Cancel</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="dropParty()">Drop</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelParty()">Exit</button>
<button type="button" class="pg-btn pg-btn--primary" (click)="addParty()">Save</button>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Party Code</th>
<th>Bond Type</th>
<th>Party Name</th>
<th>City</th>
<th>Bank Type</th>
<th>GL A/c</th>
<th class="num">Limit</th>
<th>Counterparty Bank / IFSC</th>
<th>Status</th>
<th>Type</th>
<th>PAN No</th>
</tr>
</thead>
<tbody>
@@ -220,14 +146,9 @@
<td>
<span class="pg-chip">{{ p.partyCode }}</span>
</td>
<td>{{ p.bondType || '—' }}</td>
<td>{{ p.partyName }}</td>
<td>{{ p.city || '—' }}</td>
<td>{{ p.bankType || '—' }}</td>
<td>{{ p.glCode ? p.glCode + ' — ' + glName(p.glCode) : '—' }}</td>
<td class="num">{{ p.limit != null ? '₹' + (p.limit | number: '1.0-0') : '—' }}</td>
<td>{{ p.cpBankName ? p.cpBankName + (p.cpIfsc ? ' / ' + p.cpIfsc : '') : '—' }}</td>
<td>{{ p.status ?? 'ACTIVE' }}</td>
<td>{{ p.partyType }}</td>
<td>{{ p.panNo }}</td>
</tr>
}
</tbody>
@@ -235,118 +156,42 @@
</div>
}
<!-- SCRIPT MASTER (layout follows the bank's existing CBS Script Master) -->
<!-- SCRIPT MASTER -->
@if (activeTab === 'script') {
<div class="pg-card">
<div class="pos-section-label">Script Master — Entry</div>
<div class="pos-section-label">Add Script</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Script Code</span><input type="text" [(ngModel)]="newScript.scriptCode"
/></label>
<label class="inv-form-field"
><span>Script Name *</span><input type="text" [(ngModel)]="newScript.scriptName"
/></label>
<label class="inv-form-field">
<span>* Bond Type</span>
<select [(ngModel)]="scriptForm.instrumentType" [disabled]="!!scriptForm.scriptCode">
<span>Instrument Type *</span>
<select [(ngModel)]="newScript.instrumentType">
<option [ngValue]="undefined">-- Select --</option>
@for (t of instrumentTypes; track t) {
<option [value]="t">{{ t }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>SLR</span>
<select [(ngModel)]="scriptForm.slrType">
<option [ngValue]="undefined">-- Select --</option>
@for (o of slrTypeOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</label>
<label class="inv-form-field"><span>ISIN</span><input type="text" [(ngModel)]="newScript.isin" /></label>
<label class="inv-form-field"
><span>* Script Code</span><input type="text" [value]="scriptForm.scriptCode || nextScriptCode" readonly
/></label>
<label class="inv-form-field"
><span>* Script Desc</span
><input type="text" placeholder="e.g. 7.50%GOI2034" [(ngModel)]="scriptForm.scriptName"
/></label>
<label class="inv-form-field">
<span>* GL A/c No.</span>
<select [(ngModel)]="scriptForm.glAccountId">
<option [ngValue]="undefined">-- Select --</option>
@for (g of investmentGlOptions; track g.id) {
<option [ngValue]="g.id">{{ g.glCode }} — {{ g.name }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>{{ scriptIsDebtSecurity ? '* ' : '' }}ISIN</span><input type="text" [(ngModel)]="scriptForm.isin"
/></label>
<label class="inv-form-field"
><span>{{ scriptIsDebtSecurity ? '* ' : '' }}Int Rate %</span
><input type="number" step="0.01" min="0" [(ngModel)]="scriptForm.couponRate"
/></label>
<label class="inv-form-field"
><span>{{ scriptIsDebtSecurity ? '* ' : '' }}Issue Date</span
><mifosx-date-ddmmyyyy [(ngModel)]="scriptForm.issueDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Redum / Mat Date</span
><mifosx-date-ddmmyyyy [(ngModel)]="scriptForm.maturityDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Period (Year)</span><input type="text" [value]="scriptPeriod.years" readonly
/></label>
<label class="inv-form-field"
><span>Period (Month)</span><input type="text" [value]="scriptPeriod.months" readonly
/></label>
<label class="inv-form-field"
><span>Period (Days)</span><input type="text" [value]="scriptPeriod.days" readonly
/></label>
</div>
<div class="pos-section-label">Company Details (Bond / Mutual Fund)</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Company Name</span><input type="text" [(ngModel)]="scriptForm.companyName"
/></label>
<label class="inv-form-field"
><span>Address Line 1 (Building / Flat No.)</span
><input type="text" placeholder="e.g. Shop No. 12, Shanti Complex" [(ngModel)]="scriptForm.address1"
/></label>
<label class="inv-form-field"
><span>Address Line 2 (Street / Area)</span
><input type="text" placeholder="e.g. MG Road, Near Bus Stand" [(ngModel)]="scriptForm.address2"
/></label>
<label class="inv-form-field"
><span>Address Line 3 (Locality / Landmark)</span
><input type="text" placeholder="e.g. Arera Colony" [(ngModel)]="scriptForm.address3"
/></label>
<label class="inv-form-field"><span>City</span><input type="text" [(ngModel)]="scriptForm.city" /></label>
<label class="inv-form-field"
><span>Pincode</span><input type="text" maxlength="6" [(ngModel)]="scriptForm.pincode"
/></label>
<label class="inv-form-field"><span>Phone</span><input type="text" [(ngModel)]="scriptForm.phone" /></label>
<label class="inv-form-field"><span>Fax</span><input type="text" [(ngModel)]="scriptForm.fax" /></label>
<label class="inv-form-field"
><span>Corp. Code</span><input type="text" [(ngModel)]="scriptForm.corpCode"
><span>Face Value / Unit</span><input type="number" [(ngModel)]="newScript.faceValuePerUnit"
/></label>
</div>
<div class="inv-form-actions">
<label class="inv-form-field"
><span>Search Script Code</span><input type="text" placeholder="e.g. G00001" [(ngModel)]="scriptSearchCode"
/></label>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="saving" (click)="saveScript()">Save</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="searchScript()">Search</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelScript()">Cancel</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelScript()">Exit</button>
<button type="button" class="pg-btn pg-btn--primary" (click)="addScript()">Save</button>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Script Code</th>
<th>Bond Type</th>
<th>SLR</th>
<th>Script Desc</th>
<th>GL A/c</th>
<th>Script Name</th>
<th>Instrument</th>
<th>ISIN</th>
<th class="num">Int Rate %</th>
<th>Issue Date</th>
<th>Maturity</th>
<th class="num">Face Value/Unit</th>
</tr>
</thead>
<tbody>
@@ -355,14 +200,10 @@
<td>
<span class="pg-chip">{{ s.scriptCode }}</span>
</td>
<td>{{ s.instrumentType }}</td>
<td>{{ s.slrType || '—' }}</td>
<td>{{ s.scriptName }}</td>
<td>{{ s.glCode || '—' }}</td>
<td>{{ s.instrumentType }}</td>
<td>{{ s.isin || '—' }}</td>
<td class="num">{{ s.couponRate ?? '—' }}</td>
<td>{{ s.issueDate || '—' }}</td>
<td>{{ s.maturityDate || '—' }}</td>
<td class="num">{{ s.faceValuePerUnit }}</td>
</tr>
}
</tbody>
@@ -6,12 +6,14 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import {
SCRIPT_MASTER,
BROKER_MASTER,
PARTY_MASTER,
SUBTYPE_MASTER,
CONTROL_PARAMETERS,
MUTUAL_FUND_PRODUCTS,
@@ -29,14 +31,9 @@ import {
ScriptMarketDetail,
DailyScriptRate,
LienReason,
InstrumentType,
BANK_TYPE_OPTIONS,
SLR_TYPE_OPTIONS,
INVESTMENT_GL_LOOKUP,
periodBetween
InstrumentType
} from '../investment-data';
import { exportWorkbook, ExportSheetSpec } from '../investment-export';
import { InvestmentGlAccount, InvestmentService, apiErrorMessage } from '../investment.service';
type MasterTab =
| 'party'
@@ -68,12 +65,12 @@ type MasterTab =
DateDdmmyyyyPickerComponent
]
})
export class InvestmentMastersComponent implements OnInit {
export class InvestmentMastersComponent {
activeTab: MasterTab = 'party';
scripts: ScriptMaster[] = [];
scripts: ScriptMaster[] = [...SCRIPT_MASTER];
brokers: BrokerMaster[] = [...BROKER_MASTER];
parties: PartyMaster[] = [];
parties: PartyMaster[] = [...PARTY_MASTER];
subTypes: SubTypeMaster[] = [...SUBTYPE_MASTER];
controlParams: ControlParameter[] = [...CONTROL_PARAMETERS];
mfProducts: MutualFundProductMaster[] = [...MUTUAL_FUND_PRODUCTS];
@@ -109,19 +106,8 @@ export class InvestmentMastersComponent implements OnInit {
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
// Bank / Party Master and Script Master follow the bank's existing CBS screens (one entry form
// with Save / Search / Cancel / Drop / Exit) and are stored in the backend through
// InvestmentService; the other master tabs are still sample data.
private readonly investmentService = inject(InvestmentService);
partyForm: Partial<PartyMaster> = {};
partySearchCode = '';
scriptForm: Partial<ScriptMaster> = {};
scriptSearchCode = '';
saving = false;
readonly bankTypeOptions = BANK_TYPE_OPTIONS;
readonly slrTypeOptions = SLR_TYPE_OPTIONS;
/** Enabled ASSET detail GLs from the chart of accounts. */
investmentGlOptions: InvestmentGlAccount[] = [];
newParty: Partial<PartyMaster> = {};
newScript: Partial<ScriptMaster> = {};
newSubType: Partial<SubTypeMaster> = {};
newBroker: Partial<BrokerMaster> = {};
newControlParam: Partial<ControlParameter> = {};
@@ -130,216 +116,43 @@ export class InvestmentMastersComponent implements OnInit {
newSglBank: Partial<SglBankMaster> = {};
newDailyRate: Partial<DailyScriptRate> = {};
ngOnInit(): void {
this.investmentService.getAssetGlAccounts().subscribe({
next: (gls) => (this.investmentGlOptions = gls),
error: (err) => this.toast(`Could not load GL accounts: ${apiErrorMessage(err)}`)
});
this.reloadMasters();
}
setTab(tab: MasterTab): void {
this.activeTab = tab;
}
/** Re-reads both masters from the backend (also refreshes the lists the Purchase screen uses). */
private reloadMasters(): void {
this.investmentService.syncSharedMasters().subscribe({
next: ({ parties, scripts }) => {
this.parties = parties;
this.scripts = scripts;
},
error: (err) => this.toast(`Could not load masters: ${apiErrorMessage(err)}`)
});
}
glName(glCode?: string): string {
return (
this.investmentGlOptions.find((g) => g.glCode === glCode)?.name ??
INVESTMENT_GL_LOOKUP.find((g) => g.glCode === glCode)?.glName ??
''
);
}
// -------------------- Bank / Party Master --------------------
/** Preview of the code the backend will assign; the saved code always comes from the backend. */
get nextPartyCode(): string {
const nums = this.parties.map((p) => parseInt(p.partyCode.replace(/\D/g, ''), 10)).filter((n) => !isNaN(n));
return `PTY-${String((nums.length ? Math.max(...nums) : 0) + 1).padStart(3, '0')}`;
}
saveParty(): void {
const f = this.partyForm;
if (!f.bondType || !f.partyName || !f.glAccountId) {
this.toast('Enter Bond Type, Party Name and GL A/c No.');
addParty(): void {
if (!this.newParty.partyName || !this.newParty.partyType) {
this.toast('Enter Party Name and Type.');
return;
}
const body = {
bondType: f.bondType,
partyName: f.partyName,
address1: f.address1 ?? '',
address2: f.address2 ?? '',
address3: f.address3 ?? '',
city: f.city ?? '',
pincode: f.pincode ?? '',
phone: f.phone ?? '',
fax: f.fax ?? '',
bankType: f.bankType ?? '',
glAccountId: Number(f.glAccountId),
rbiCode: f.rbiCode ?? '',
limit: f.limit ?? null,
leiNo: f.leiNo ?? '',
cpBankName: f.cpBankName ?? '',
cpBranchName: f.cpBranchName ?? '',
cpAccountNo: f.cpAccountNo ?? '',
cpIfsc: f.cpIfsc ?? '',
active: f.status !== 'INACTIVE',
locale: 'en'
};
const request = f.id ? this.investmentService.updateParty(f.id, body) : this.investmentService.createParty(body);
this.saving = true;
request.subscribe({
next: (res) => {
this.saving = false;
this.toast(`Party ${res.resourceIdentifier ?? f.partyCode} ${f.id ? 'updated' : 'saved'}.`);
this.partyForm = {};
this.reloadMasters();
},
error: (err) => {
this.saving = false;
this.toast(apiErrorMessage(err));
}
});
this.parties = [
{
partyCode: `PTY-${String(this.parties.length + 1).padStart(3, '0')}`,
panNo: '',
...this.newParty
} as PartyMaster,
...this.parties
];
this.newParty = {};
this.toast('Party/Bank master added.');
}
searchParty(): void {
const p = this.parties.find((x) => x.partyCode.toUpperCase() === this.partySearchCode.trim().toUpperCase());
if (!p) {
this.toast(`Party Code ${this.partySearchCode} not found.`);
addScript(): void {
if (!this.newScript.scriptName || !this.newScript.instrumentType) {
this.toast('Enter Script Name and Instrument Type.');
return;
}
this.partyForm = { ...p };
this.toast(`Party ${p.partyCode} loaded.`);
}
cancelParty(): void {
this.partyForm = {};
this.partySearchCode = '';
}
dropParty(): void {
const f = this.partyForm;
if (!f.id) {
this.toast('Search a saved party first, then Drop.');
return;
}
this.investmentService.deleteParty(f.id).subscribe({
next: () => {
this.toast(`Party ${f.partyCode} dropped.`);
this.partyForm = {};
this.reloadMasters();
},
error: (err) => this.toast(apiErrorMessage(err))
});
}
// -------------------- Script Master --------------------
/** Coupon / maturity terms only apply to debt securities. */
get scriptIsDebtSecurity(): boolean {
return [
'GOVT_SECURITY',
'NON_SLR',
'BOND'
].includes(this.scriptForm.instrumentType ?? '');
}
/** Preview of the code the backend will assign — a type letter + running number, e.g. G00001. */
get nextScriptCode(): string {
const prefixByType: Record<InstrumentType, string> = {
GOVT_SECURITY: 'G',
NON_SLR: 'N',
BOND: 'B',
MUTUAL_FUND: 'M',
FD: 'F',
SHARE: 'S'
};
const type = this.scriptForm.instrumentType;
if (!type) {
return '';
}
const prefix = prefixByType[type];
const pattern = new RegExp(`^${prefix}(\\d{5})$`);
const nums = this.scripts
.map((s) => pattern.exec(s.scriptCode)?.[1])
.filter((n): n is string => !!n)
.map((n) => parseInt(n, 10));
return `${prefix}${String((nums.length ? Math.max(...nums) : 0) + 1).padStart(5, '0')}`;
}
get scriptPeriod(): { years: number; months: number; days: number } {
return periodBetween(this.scriptForm.issueDate, this.scriptForm.maturityDate);
}
saveScript(): void {
const f = this.scriptForm;
if (!f.instrumentType || !f.scriptName || !f.glAccountId) {
this.toast('Enter Bond Type, Script Desc and GL A/c No.');
return;
}
if (this.scriptIsDebtSecurity && (!f.isin || f.couponRate == null || !f.issueDate)) {
this.toast('Enter ISIN, Int Rate and Issue Date for this security.');
return;
}
const body = {
bondType: f.instrumentType,
slrType: f.slrType ?? '',
scriptDesc: f.scriptName,
glAccountId: Number(f.glAccountId),
isin: f.isin ?? '',
intRate: f.couponRate ?? null,
issueDate: f.issueDate ?? '',
maturityDate: f.maturityDate ?? '',
companyName: f.companyName ?? '',
address1: f.address1 ?? '',
address2: f.address2 ?? '',
address3: f.address3 ?? '',
city: f.city ?? '',
pincode: f.pincode ?? '',
phone: f.phone ?? '',
fax: f.fax ?? '',
corpCode: f.corpCode ?? '',
dateFormat: 'dd-MM-yyyy',
locale: 'en'
};
const request = f.id ? this.investmentService.updateScript(f.id, body) : this.investmentService.createScript(body);
this.saving = true;
request.subscribe({
next: (res) => {
this.saving = false;
this.toast(`Script ${res.resourceIdentifier ?? f.scriptCode} ${f.id ? 'updated' : 'saved'}.`);
this.scriptForm = {};
this.reloadMasters();
},
error: (err) => {
this.saving = false;
this.toast(apiErrorMessage(err));
}
});
}
searchScript(): void {
const s = this.scripts.find((x) => x.scriptCode.toUpperCase() === this.scriptSearchCode.trim().toUpperCase());
if (!s) {
this.toast(`Script Code ${this.scriptSearchCode} not found.`);
return;
}
this.scriptForm = { ...s };
this.toast(`Script ${s.scriptCode} loaded.`);
}
cancelScript(): void {
this.scriptForm = {};
this.scriptSearchCode = '';
this.scripts = [
{
scriptCode: this.newScript.scriptCode || `SCR-${this.scripts.length + 1}`,
isin: '',
faceValuePerUnit: 100,
...this.newScript
} as ScriptMaster,
...this.scripts
];
this.newScript = {};
this.toast('Script master added.');
}
addSubType(): void {
@@ -466,121 +279,46 @@ export class InvestmentMastersComponent implements OnInit {
return one(
[
'Party Code',
'Bond Type',
'Party Name',
'Address',
'City',
'Pincode',
'Phone',
'Fax',
'Bank Type',
'GL A/c No',
'RBI Code',
'Limit',
'LEI No',
'Counterparty Bank',
'Branch',
'Account No',
'IFSC',
'Status'
'Type',
'PAN No'
],
[
12,
14,
30,
36,
14,
10,
14,
14,
24,
12,
12,
14,
22,
24,
20,
18,
14,
10
16,
14
],
this.parties.map((p) => [
p.partyCode,
p.bondType ?? '',
p.partyName,
[
p.address1,
p.address2,
p.address3
]
.filter((x) => !!x)
.join(', '),
p.city ?? '',
p.pincode ?? '',
p.phone ?? '',
p.fax ?? '',
p.bankType ?? '',
p.glCode ?? '',
p.rbiCode ?? '',
p.limit ?? null,
p.leiNo ?? '',
p.cpBankName ?? '',
p.cpBranchName ?? '',
p.cpAccountNo ?? '',
p.cpIfsc ?? '',
p.status ?? 'ACTIVE'
p.partyType,
p.panNo
])
);
case 'script':
return one(
[
'Script Code',
'Bond Type',
'SLR',
'Script Desc',
'GL A/c No',
'Script Name',
'Instrument',
'ISIN',
'Int Rate %',
'Issue Date',
'Maturity Date',
'Period (Y/M/D)',
'Face Value/Unit',
'Company Name',
'Corp Code'
'Face Value/Unit'
],
[
14,
16,
12,
30,
12,
16,
10,
14,
14,
14,
14,
24,
12
16,
16
],
this.scripts.map((s) => {
const p = periodBetween(s.issueDate, s.maturityDate);
return [
s.scriptCode,
s.instrumentType,
s.slrType ?? '',
s.scriptName,
s.glCode ?? '',
s.isin,
s.couponRate ?? null,
s.issueDate ?? '',
s.maturityDate ?? '',
s.issueDate && s.maturityDate ? `${p.years}/${p.months}/${p.days}` : '',
s.faceValuePerUnit,
s.companyName ?? '',
s.corpCode ?? ''
];
})
this.scripts.map((s) => [
s.scriptCode,
s.scriptName,
s.instrumentType,
s.isin,
s.faceValuePerUnit
])
);
case 'subtype':
return one(
-344
View File
@@ -1,344 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Observable, forkJoin } from 'rxjs';
import { map, tap } from 'rxjs/operators';
import {
InstrumentType,
InvestmentHolding,
PARTY_MASTER,
PartyMaster,
SCRIPT_MASTER,
ScriptMaster,
SlrType
} from './investment-data';
/** Investment party as returned by /investment/parties. */
export interface InvestmentPartyDto {
id: number;
partyCode: string;
bondType: InstrumentType;
partyName: string;
address1?: string;
address2?: string;
address3?: string;
city?: string;
pincode?: string;
phone?: string;
fax?: string;
bankType?: string;
glAccountId: number;
glCode: string;
glName: string;
rbiCode?: string;
limit?: number;
leiNo?: string;
cpBankName?: string;
cpBranchName?: string;
cpAccountNo?: string;
cpIfsc?: string;
active: boolean;
}
/** Investment script as returned by /investment/scripts (dates are DD-MM-YYYY). */
export interface InvestmentScriptDto {
id: number;
scriptCode: string;
bondType: InstrumentType;
slrType?: SlrType;
scriptDesc: string;
glAccountId: number;
glCode: string;
glName: string;
isin?: string;
faceValuePerUnit: number;
intRate?: number;
issueDate?: string;
maturityDate?: string;
companyName?: string;
address1?: string;
address2?: string;
address3?: string;
city?: string;
pincode?: string;
phone?: string;
fax?: string;
corpCode?: string;
active: boolean;
}
/** Purchase entry (holding) as returned by /investment/purchases (dates are DD-MM-YYYY). */
export interface InvestmentPurchaseDto {
id: number;
dealNo: string;
certNo: string;
bondType: InstrumentType;
scriptId: number;
scriptCode: string;
scriptDesc: string;
isin?: string;
partyId: number;
partyCode: string;
partyName: string;
dealDate: string;
dealTime?: string;
settlementType: string;
settlementDate: string;
faceValue: number;
purchaseRate: number;
units: number;
balanceUnits: number;
brokerage?: number;
stampDuty?: number;
bookValue: number;
premiumDiscount?: number;
intRate?: number;
issueDate?: string;
maturityDate?: string;
lastIntDate?: string;
accruedDays?: number;
accruedInterest?: number;
settlementAmount: number;
ytm?: number;
category: string;
subType?: string;
investmentGlAccountId: number;
investmentGlCode: string;
investmentGlName: string;
crGlAccountId: number;
crGlCode: string;
crGlName: string;
otherDetails?: string;
status: string;
}
export interface InvestmentGlAccount {
id: number;
glCode: string;
name: string;
}
/**
* Backend calls for the Investment masters (Bank / Party Master and Script Master). Results are also copied into the
* shared PARTY_MASTER / SCRIPT_MASTER lists so screens that still read those lists (Purchase Entry) offer the saved
* masters.
*/
@Injectable({ providedIn: 'root' })
export class InvestmentService {
private http = inject(HttpClient);
// -------------------- Bank / Party Master --------------------
getParties(): Observable<InvestmentPartyDto[]> {
return this.http.get<InvestmentPartyDto[]>('/investment/parties');
}
createParty(body: object): Observable<{ resourceId: number; resourceIdentifier: string }> {
return this.http.post<{ resourceId: number; resourceIdentifier: string }>('/investment/parties', body);
}
updateParty(id: number, body: object): Observable<{ resourceId: number; resourceIdentifier: string }> {
return this.http.put<{ resourceId: number; resourceIdentifier: string }>(`/investment/parties/${id}`, body);
}
deleteParty(id: number): Observable<unknown> {
return this.http.delete(`/investment/parties/${id}`);
}
// -------------------- Script Master --------------------
getScripts(): Observable<InvestmentScriptDto[]> {
return this.http.get<InvestmentScriptDto[]>('/investment/scripts');
}
createScript(body: object): Observable<{ resourceId: number; resourceIdentifier: string }> {
return this.http.post<{ resourceId: number; resourceIdentifier: string }>('/investment/scripts', body);
}
updateScript(id: number, body: object): Observable<{ resourceId: number; resourceIdentifier: string }> {
return this.http.put<{ resourceId: number; resourceIdentifier: string }>(`/investment/scripts/${id}`, body);
}
// -------------------- Purchase Entry --------------------
getPurchases(): Observable<InvestmentPurchaseDto[]> {
return this.http.get<InvestmentPurchaseDto[]>('/investment/purchases');
}
/** Coupon and 31 Mar / 30 Sep provision lines of a holding up to maturity (calculated, not posted). */
getInterestSchedule(purchaseId: number, asOnDate?: string): Observable<InvestmentInterestScheduleDto> {
const params = asOnDate ? `?asOnDate=${encodeURIComponent(asOnDate)}` : '';
return this.http.get<InvestmentInterestScheduleDto>(
`/investment/purchases/${purchaseId}/interest-schedule${params}`
);
}
createPurchase(body: object): Observable<{ resourceId: number; resourceIdentifier: string }> {
return this.http.post<{ resourceId: number; resourceIdentifier: string }>('/investment/purchases', body);
}
deletePurchase(id: number): Observable<unknown> {
return this.http.delete(`/investment/purchases/${id}`);
}
// -------------------- GL accounts --------------------
/** Enabled ASSET detail GLs — the only kind an investment party / script can be booked to. */
getAssetGlAccounts(): Observable<InvestmentGlAccount[]> {
return this.http
.get<
Array<{ id: number; glCode: string; name: string; disabled: boolean }>
>('/glaccounts', { params: { type: '1', usage: '1', disabled: 'false' } })
.pipe(
map((list) =>
list
.map((g) => ({ id: g.id, glCode: g.glCode, name: g.name }))
.sort((a, b) => a.glCode.localeCompare(b.glCode))
)
);
}
/** Loads both masters from the backend and refreshes the shared lists other screens read. */
syncSharedMasters(): Observable<{ parties: PartyMaster[]; scripts: ScriptMaster[] }> {
return forkJoin({ parties: this.getParties(), scripts: this.getScripts() }).pipe(
map(({ parties, scripts }) => ({ parties: parties.map(toPartyMaster), scripts: scripts.map(toScriptMaster) })),
tap(({ parties, scripts }) => {
PARTY_MASTER.splice(0, PARTY_MASTER.length, ...parties);
SCRIPT_MASTER.splice(0, SCRIPT_MASTER.length, ...scripts);
})
);
}
}
export function toPartyMaster(p: InvestmentPartyDto): PartyMaster {
return {
id: p.id,
partyCode: p.partyCode,
partyName: p.partyName,
partyType: 'BANK',
bondType: p.bondType,
address1: p.address1,
address2: p.address2,
address3: p.address3,
city: p.city,
pincode: p.pincode,
phone: p.phone,
fax: p.fax,
bankType: p.bankType,
glAccountId: p.glAccountId,
glCode: p.glCode,
glName: p.glName,
rbiCode: p.rbiCode,
limit: p.limit ?? null,
leiNo: p.leiNo,
cpBankName: p.cpBankName,
cpBranchName: p.cpBranchName,
cpAccountNo: p.cpAccountNo,
cpIfsc: p.cpIfsc,
status: p.active ? 'ACTIVE' : 'INACTIVE'
};
}
export function toScriptMaster(s: InvestmentScriptDto): ScriptMaster {
return {
id: s.id,
scriptCode: s.scriptCode,
scriptName: s.scriptDesc,
instrumentType: s.bondType,
isin: s.isin ?? '',
faceValuePerUnit: s.faceValuePerUnit,
slrType: s.slrType,
glAccountId: s.glAccountId,
glCode: s.glCode,
glName: s.glName,
couponRate: s.intRate,
issueDate: s.issueDate,
maturityDate: s.maturityDate,
companyName: s.companyName,
address1: s.address1,
address2: s.address2,
address3: s.address3,
city: s.city,
pincode: s.pincode,
phone: s.phone,
fax: s.fax,
corpCode: s.corpCode,
active: s.active
};
}
/** A saved purchase as a row of the Holdings register. */
export function toHolding(p: InvestmentPurchaseDto): InvestmentHolding {
return {
id: p.id,
dealNo: p.dealNo,
certNo: p.certNo,
instrumentType: p.bondType,
scriptCode: p.scriptCode,
scriptName: p.scriptDesc,
party: p.partyName,
partyCode: p.partyCode,
subType: p.subType ?? '',
faceValue: p.faceValue,
purchaseRate: p.purchaseRate,
units: p.balanceUnits,
purchaseDate: p.settlementDate,
maturityDate: p.maturityDate || p.dealDate,
interestRate: p.intRate ?? 0,
interestFreq: p.bondType === 'MUTUAL_FUND' ? 'CUMULATIVE' : 'HALF_YEARLY',
glCode: p.investmentGlCode,
glName: p.investmentGlName,
crToAc: p.crGlCode,
brokerCode: '',
status: 'ACTIVE',
category: p.category,
bookValue: p.bookValue,
settlementAmount: p.settlementAmount
};
}
/** One line of a holding's interest schedule (see InvestmentInterestScheduleData in the backend). */
export interface InvestmentInterestRowDto {
type: 'COUPON' | 'PROVISION';
intDate: string;
fromDate: string;
toDate: string;
days: number;
grossInterest: number;
alreadyBooked: number;
amount: number;
due: boolean;
}
export interface InvestmentInterestScheduleDto {
purchaseId: number;
dealNo: string;
certNo: string;
scriptCode: string;
scriptDesc: string;
faceValue: number;
unitValue: number;
balanceUnits: number;
purchaseRate: number;
bookValue: number;
intRate: number;
dealDate: string;
lastIntDate: string;
maturityDate: string;
purchasedAccruedInterest: number;
couponPerHalfYear: number;
asOnDate: string;
interestGlCode: string;
interestGlName: string;
rows: InvestmentInterestRowDto[];
}
/** The first validation / domain-rule message from a Fineract error response, for a toast. */
export function apiErrorMessage(err: unknown): string {
const e = err as HttpErrorResponse;
const body = e?.error;
return body?.errors?.[0]?.defaultUserMessage || body?.defaultUserMessage || e?.message || 'Request failed.';
}
+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;
@@ -144,7 +144,7 @@
</button>
}
@if (savingProductFormValid && selectedTabIndex === totalTabs - 1) {
<button mat-raised-button color="primary" (click)="submit()">
<button mat-raised-button color="primary" [disabled]="submitting" (click)="submit()">
<i class="fas fa-check" style="margin-right: 8px"></i>
{{ 'labels.buttons.Submit' | translate }}
</button>
@@ -70,6 +70,10 @@ export class CreateSavingProductComponent {
savingProductsTemplate: any;
accountingRuleData: string[] = [];
selectedTabIndex = 0;
/** Guards against repeat clicks on Submit: the product name is unique in the database, so a
* second request for the same form fails with a duplicate key error even though the first one
* created the product. */
submitting = false;
/**
* @param {ActivatedRoute} route Activated Route.
@@ -162,8 +166,11 @@ export class CreateSavingProductComponent {
}
submit() {
if (this.submitting) {
return;
}
this.submitting = true;
const savingProduct = this.prepareSavingProductPayload();
console.log('Saving product submit payload:', savingProduct);
this.productsService.createSavingProduct(savingProduct).subscribe({
next: (response: any) => {
if (response?.rollbackTransaction) {
@@ -181,6 +188,11 @@ export class CreateSavingProductComponent {
{ relativeTo: this.route }
);
}
},
error: () => {
// the error itself is reported by the global error handler; just let the user correct the
// form and try again
this.submitting = false;
}
});
}
@@ -92,6 +92,14 @@ const routes: Routes = [
(m) => m.InterestPostingReportComponent
)
},
{
path: 'fd-interest-posting',
data: { title: 'FD Interest Posting Reports', breadcrumb: 'FD Interest Posting Reports' },
loadComponent: () =>
import('../fd-interest-posting-report/fd-interest-posting-report.component').then(
(m) => m.FdInterestPostingReportComponent
)
},
{
path: 'dormancy',
data: { title: 'Inactive, Dormant & DEAF', breadcrumb: 'Inactive, Dormant & DEAF' },
@@ -0,0 +1,80 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
@if (active) {
@if (currentMode === 'overlay') {
<!-- Mifos Mobile Transaction / Action Progress Overlay -->
<div class="cbs-loading-backdrop" (click)="preventDismiss($event)">
<div class="cbs-loading-dialog" role="dialog" aria-modal="true" [attr.aria-label]="title || 'Loading'">
<div class="cbs-lottie-container" #lottieContainer>
<!-- Vector Fallback if Lottie is loading -->
@if (!lottieLoaded) {
<div class="cbs-vector-spinner">
<div class="spinner-ring ring-outer"></div>
<div class="spinner-ring ring-mid"></div>
<div class="spinner-ring ring-inner"></div>
</div>
}
</div>
<div class="cbs-loading-dialog__content">
<h3 class="cbs-loading-dialog__title">{{ title || 'CBS Nexora' }}</h3>
<p class="cbs-loading-dialog__message">{{ message || 'Please wait...' }}</p>
</div>
<div class="cbs-loading-pulse-bar">
<div class="cbs-pulse-bar__indicator"></div>
</div>
</div>
</div>
}
@if (currentMode === 'fullscreen') {
<!-- Full-Screen App Boot / Splash Screen -->
<div class="cbs-fullscreen-loader">
<div class="cbs-fullscreen-loader__brand">
<img src="assets/images/cbsnexora-logo.png" alt="CBS Nexora" class="cbs-fullscreen-loader__logo-img" />
<span class="cbs-brand-sub">Core Banking System</span>
</div>
<div class="cbs-lottie-container cbs-lottie-container--large" #lottieContainer>
@if (!lottieLoaded) {
<div class="cbs-vector-spinner cbs-vector-spinner--large">
<div class="spinner-ring ring-outer"></div>
<div class="spinner-ring ring-mid"></div>
<div class="spinner-ring ring-inner"></div>
</div>
}
</div>
<div class="cbs-progress-track">
<div class="cbs-progress-fill"></div>
</div>
<p class="cbs-fullscreen-loader__status">{{ message || 'Connecting to Core Banking Engine...' }}</p>
</div>
}
@if (currentMode === 'inline') {
<!-- Inline Component Loader -->
<div class="cbs-inline-loader">
<div class="cbs-lottie-container cbs-lottie-container--compact" #lottieContainer>
@if (!lottieLoaded) {
<div class="cbs-vector-spinner cbs-vector-spinner--compact">
<div class="spinner-ring ring-outer"></div>
<div class="spinner-ring ring-mid"></div>
<div class="spinner-ring ring-inner"></div>
</div>
}
</div>
@if (message) {
<p class="cbs-inline-loader__message">{{ message }}</p>
}
</div>
}
}
@@ -0,0 +1,349 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
:host {
display: block;
}
/* ==========================================================================
Overlay Mode (MifosProgressIndicatorOverlay / MifosLoadingDialog)
========================================================================== */
.cbs-loading-backdrop {
position: fixed;
inset: 0;
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
background: rgb(9 13 22 / 72%);
backdrop-filter: blur(8px);
animation: backdrop-fade-in 0.25s ease-out forwards;
}
.cbs-loading-dialog {
position: relative;
width: 90%;
max-width: 340px;
padding: 28px 24px;
background: #111827;
color: #f8fafc;
border-radius: 20px;
border: 1px solid rgb(255 255 255 / 12%);
box-shadow:
0 25px 60px -15px rgb(0 0 0 / 65%),
0 0 35px rgb(37 99 235 / 18%);
text-align: center;
overflow: hidden;
animation: dialog-scale-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
:host-context([data-theme='light']) .cbs-loading-dialog,
:host-context(.light-theme) .cbs-loading-dialog {
background: #fff;
color: #0f172a;
border-color: rgb(0 0 0 / 8%);
box-shadow:
0 25px 50px -12px rgb(0 0 0 / 25%),
0 0 25px rgb(37 99 235 / 12%);
}
.cbs-loading-dialog__content {
margin-top: 10px;
margin-bottom: 16px;
}
.cbs-loading-dialog__title {
font-size: 1.05rem;
font-weight: 700;
letter-spacing: -0.01em;
margin: 0 0 6px;
}
.cbs-loading-dialog__message {
font-size: 0.82rem;
color: #94a3b8;
line-height: 1.45;
margin: 0;
}
:host-context([data-theme='light']) .cbs-loading-dialog__message,
:host-context(.light-theme) .cbs-loading-dialog__message {
color: #64748b;
}
/* Pulse bar at dialog bottom */
.cbs-loading-pulse-bar {
width: 100%;
height: 3px;
background: rgb(255 255 255 / 8%);
border-radius: 99px;
overflow: hidden;
position: relative;
margin-top: 6px;
}
.cbs-pulse-bar__indicator {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 40%;
background: linear-gradient(90deg, #2563eb, #38bdf8);
border-radius: 99px;
animation: pulse-bar-slide 1.4s infinite ease-in-out;
}
/* ==========================================================================
Full-Screen Mode (App Boot / Splash)
========================================================================== */
.cbs-fullscreen-loader {
position: fixed;
inset: 0;
z-index: 99999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: radial-gradient(circle at center, #0f172a 0%, #090d16 100%);
color: #f8fafc;
padding: 24px;
text-align: center;
}
:host-context([data-theme='light']) .cbs-fullscreen-loader,
:host-context(.light-theme) .cbs-fullscreen-loader {
background: radial-gradient(circle at center, #fff 0%, #f1f5f9 100%);
color: #0f172a;
}
.cbs-fullscreen-loader__brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.cbs-fullscreen-loader__logo-img {
height: 52px;
width: auto;
max-width: 260px;
object-fit: contain;
filter: drop-shadow(0 6px 18px rgb(0 0 0 / 45%));
}
.cbs-brand-icon {
width: 64px;
height: 64px;
border-radius: 16px;
background: linear-gradient(135deg, #1e3a8a 0%, #0284c7 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10px 24px rgb(14 165 233 / 35%);
margin-bottom: 6px;
}
.cbs-brand-name {
font-size: 1.5rem;
font-weight: 800;
letter-spacing: -0.02em;
margin: 0;
}
.cbs-brand-sub {
font-size: 0.8rem;
color: #94a3b8;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.cbs-progress-track {
width: 240px;
height: 5px;
background: rgb(255 255 255 / 10%);
border-radius: 99px;
overflow: hidden;
margin: 18px 0 12px;
position: relative;
}
.cbs-progress-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 45%;
background: linear-gradient(90deg, #2563eb, #38bdf8);
border-radius: 99px;
box-shadow: 0 0 12px rgb(56 189 248 / 50%);
animation: pulse-bar-slide 1.5s infinite ease-in-out;
}
.cbs-fullscreen-loader__status {
font-size: 0.82rem;
color: #94a3b8;
font-family: inherit;
margin: 0;
}
/* ==========================================================================
Inline Mode
========================================================================== */
.cbs-inline-loader {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
text-align: center;
}
.cbs-inline-loader__message {
font-size: 0.82rem;
color: #94a3b8;
margin-top: 10px;
}
/* ==========================================================================
Lottie & Vector Spinner Dimensions
========================================================================== */
.cbs-lottie-container {
width: 110px;
height: 110px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
.cbs-lottie-container--large {
width: 140px;
height: 140px;
}
.cbs-lottie-container--compact {
width: 64px;
height: 64px;
}
/* Vector Spinner Fallback (CSS Multi-Ring Loader) */
.cbs-vector-spinner {
width: 72px;
height: 72px;
position: relative;
}
.cbs-vector-spinner--large {
width: 96px;
height: 96px;
}
.cbs-vector-spinner--compact {
width: 44px;
height: 44px;
}
.spinner-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 3px solid transparent;
}
.ring-outer {
border-top-color: #2563eb;
border-right-color: #2563eb;
animation: spin-outer 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
}
.ring-mid {
inset: 6px;
border-bottom-color: #0ea5e9;
border-left-color: #0ea5e9;
animation: spin-mid 1.6s cubic-bezier(0.5, 0, 0.5, 1) infinite reverse;
}
.ring-inner {
inset: 13px;
border-top-color: #10b981;
animation: spin-inner 0.9s cubic-bezier(0.5, 0, 0.5, 1) infinite;
}
/* ==========================================================================
Keyframe Animations
========================================================================== */
@keyframes backdrop-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialog-scale-in {
from {
opacity: 0;
transform: scale(0.92);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes pulse-bar-slide {
0% {
left: -40%;
width: 30%;
}
50% {
left: 35%;
width: 50%;
}
100% {
left: 100%;
width: 30%;
}
}
@keyframes spin-outer {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes spin-mid {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes spin-inner {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,168 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import {
Component,
OnInit,
OnDestroy,
Input,
ElementRef,
ViewChild,
AfterViewInit,
inject,
ChangeDetectorRef
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Subscription } from 'rxjs';
import { LoadingScreenService, LoadingConfig } from './loading-screen.service';
declare const lottie: any;
@Component({
selector: 'mifosx-loading-screen',
standalone: true,
imports: [CommonModule],
templateUrl: './loading-screen.component.html',
styleUrls: ['./loading-screen.component.scss']
})
export class LoadingScreenComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly loadingService = inject(LoadingScreenService);
private readonly cdr = inject(ChangeDetectorRef);
@Input() mode: 'overlay' | 'inline' | 'fullscreen' = 'overlay';
@Input() title: string = 'CBS Nexora';
@Input() message: string = 'Processing transaction...';
@Input() active: boolean = false;
@Input() isGlobalOverlay: boolean = true;
@ViewChild('lottieContainer') lottieContainerRef?: ElementRef<HTMLDivElement>;
currentMode: 'overlay' | 'inline' | 'fullscreen' = 'overlay';
lottieLoaded: boolean = false;
private animInstance: any = null;
private subscription?: Subscription;
ngOnInit(): void {
this.currentMode = this.mode;
if (this.isGlobalOverlay) {
this.subscription = new Subscription();
this.subscription.add(
this.loadingService.isVisible$.subscribe((visible) => {
this.active = visible;
this.cdr.markForCheck();
if (visible) {
setTimeout(() => this.initLottie(), 50);
} else {
this.destroyLottie();
}
})
);
this.subscription.add(
this.loadingService.config$.subscribe((cfg: LoadingConfig) => {
if (cfg.title !== undefined) this.title = cfg.title;
if (cfg.message !== undefined) this.message = cfg.message;
if (cfg.mode !== undefined) this.currentMode = cfg.mode;
this.cdr.markForCheck();
})
);
}
}
ngAfterViewInit(): void {
if (this.active) {
this.initLottie();
}
}
ngOnDestroy(): void {
this.subscription?.unsubscribe();
this.destroyLottie();
}
preventDismiss(event: MouseEvent): void {
event.stopPropagation();
}
private initLottie(): void {
if (!this.lottieContainerRef?.nativeElement) return;
this.destroyLottie();
const container = this.lottieContainerRef.nativeElement;
// Check if lottie-web global is available
if (typeof lottie !== 'undefined') {
try {
this.animInstance = lottie.loadAnimation({
container,
renderer: 'svg',
loop: true,
autoplay: true,
path: 'assets/animations/loading_animation.json'
});
this.lottieLoaded = true;
this.cdr.markForCheck();
return;
} catch (err) {
console.warn('Could not load Lottie animation, using vector fallback', err);
}
}
// Try dynamic script load of local asset if not loaded yet
this.loadLottieScript()
.then(() => {
if (typeof lottie !== 'undefined' && this.lottieContainerRef?.nativeElement) {
this.animInstance = lottie.loadAnimation({
container: this.lottieContainerRef.nativeElement,
renderer: 'svg',
loop: true,
autoplay: true,
path: 'assets/animations/loading_animation.json'
});
this.lottieLoaded = true;
this.cdr.markForCheck();
}
})
.catch(() => {
this.lottieLoaded = false;
this.cdr.markForCheck();
});
}
private loadLottieScript(): Promise<void> {
return new Promise((resolve, reject) => {
if (typeof lottie !== 'undefined') {
resolve();
return;
}
const existingScript = document.getElementById('cbs-lottie-script');
if (existingScript) {
existingScript.addEventListener('load', () => resolve());
return;
}
const script = document.createElement('script');
script.id = 'cbs-lottie-script';
script.src = 'assets/js/lottie.min.js';
script.onload = () => resolve();
script.onerror = () => reject();
document.head.appendChild(script);
});
}
private destroyLottie(): void {
if (this.animInstance) {
try {
this.animInstance.destroy();
} catch (_) {}
this.animInstance = null;
}
}
}
@@ -0,0 +1,61 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
export interface LoadingConfig {
title?: string;
message?: string;
mode?: 'overlay' | 'inline' | 'fullscreen';
}
@Injectable({
providedIn: 'root'
})
export class LoadingScreenService {
private readonly visibleSubject = new BehaviorSubject<boolean>(false);
private readonly configSubject = new BehaviorSubject<LoadingConfig>({
title: 'CBS Nexora',
message: 'Processing transaction...',
mode: 'overlay'
});
readonly isVisible$: Observable<boolean> = this.visibleSubject.asObservable();
readonly config$: Observable<LoadingConfig> = this.configSubject.asObservable();
/**
* Show the loading screen/overlay
* @param message Optional message to display (e.g. "Processing Transfer...")
* @param title Optional title to display (defaults to "CBS Nexora")
* @param mode Optional display mode ('overlay' | 'fullscreen')
*/
show(message?: string, title?: string, mode: 'overlay' | 'fullscreen' = 'overlay'): void {
this.configSubject.next({
title: title || 'CBS Nexora',
message: message || 'Please wait...',
mode
});
this.visibleSubject.next(true);
}
/**
* Hide the loading screen/overlay
*/
hide(): void {
this.visibleSubject.next(false);
}
/**
* Update message while loading screen is active
*/
setMessage(message: string): void {
const current = this.configSubject.getValue();
this.configSubject.next({ ...current, message });
}
}
+3
View File
@@ -30,6 +30,7 @@ import { SearchToolComponent } from './search-tool/search-tool.component';
import { KeyboardShortcutsDialogComponent } from './keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component';
import { ServerSelectorComponent } from './server-selector/server-selector.component';
import { TenantSelectorComponent } from './tenant-selector/tenant-selector.component';
import { LoadingScreenComponent } from './loading-screen/loading-screen.component';
/** Custom Modules */
import { IconsModule } from './icons.module';
@@ -116,9 +117,11 @@ import { DatetimePickerStubComponent } from './datetime-picker-stub/datetime-pic
DropdownComponent,
InputAmountComponent,
InputPasswordComponent,
LoadingScreenComponent,
DatetimePickerStubComponent // temporarily added stub for datetime picker
],
exports: [
LoadingScreenComponent,
FileUploadComponent,
FooterComponent,
LanguageSelectorComponent,
@@ -130,103 +130,4 @@
<p class="empty-text">There are no accounts awaiting approval at this time.</p>
</div>
}
<!-- Share accounts awaiting approval render here too, rather than on their own tab. Own table,
own per-row approve/reject/view actions - unchanged from their previous standalone screen. -->
<h3 class="pa-section-heading">Pending Share Accounts</h3>
@if (showShareData) {
<div class="psa-action-bar">
<mat-form-field appearance="outline" class="psa-search-field">
<mat-icon matPrefix>search</mat-icon>
<input
#shareFilterInput
matInput
placeholder="Search by account number, member or product"
(keyup)="applyShareFilter(shareFilterInput.value)"
/>
</mat-form-field>
</div>
<div class="table-container">
<table mat-table [dataSource]="shareDataSource" class="modern-table">
<ng-container matColumnDef="accountNo">
<th mat-header-cell *matHeaderCellDef>Account No.</th>
<td mat-cell *matCellDef="let account" class="psa-mono">{{ account.accountNo }}</td>
</ng-container>
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef>Member</th>
<td mat-cell *matCellDef="let account">
{{ account.clientName }}
@if (account.officeName) {
<div class="psa-sub">{{ account.officeName }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="productName">
<th mat-header-cell *matHeaderCellDef>Product</th>
<td mat-cell *matCellDef="let account">{{ account.productName }}</td>
</ng-container>
<ng-container matColumnDef="requestedShares">
<th mat-header-cell *matHeaderCellDef class="ta-right">Shares</th>
<td mat-cell *matCellDef="let account" class="ta-right psa-num">{{ account.requestedShares }}</td>
</ng-container>
<ng-container matColumnDef="requestedAmount">
<th mat-header-cell *matHeaderCellDef class="ta-right">Amount</th>
<td mat-cell *matCellDef="let account" class="ta-right psa-num">
{{ account.requestedAmount | number: '1.2-2' }}
</td>
</ng-container>
<ng-container matColumnDef="resolution">
<th mat-header-cell *matHeaderCellDef>Resolution</th>
<td mat-cell *matCellDef="let account">
{{ account.resolutionNumber || '—' }}
@if (account.resolutionDate) {
<div class="psa-sub">{{ formatShareDate(account.resolutionDate) }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="submittedDate">
<th mat-header-cell *matHeaderCellDef>Submitted</th>
<td mat-cell *matCellDef="let account">
{{ formatShareDate(account.submittedDate) }}
@if (account.submittedByUsername) {
<div class="psa-sub">by {{ account.submittedByUsername }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let account">
<button mat-stroked-button class="psa-btn psa-btn-view" (click)="viewShareAccount(account)">
<fa-icon icon="eye" class="m-r-10"></fa-icon>View
</button>
<button mat-stroked-button class="psa-btn psa-btn-approve" (click)="approveShareAccount(account)">
<fa-icon icon="check" class="m-r-10"></fa-icon>Approve
</button>
<button mat-stroked-button class="psa-btn psa-btn-reject" (click)="rejectShareAccount(account)">
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="shareDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: shareDisplayedColumns" class="table-row"></tr>
</table>
</div>
}
@if (!showShareData) {
<div class="empty-state">
<fa-icon icon="hourglass-half" class="empty-icon"></fa-icon>
<p class="empty-title">No pending share accounts</p>
<p class="empty-text">There are no share accounts awaiting approval at this time.</p>
</div>
}
</div>
@@ -14,16 +14,6 @@ $text-muted: #64748b;
padding: 16px 0;
}
/* Marks where the Share Account section begins below the normal-account table on this same screen. */
.pa-section-heading {
margin: 28px 0 12px;
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
padding-top: 16px;
border-top: 1px solid $border;
}
.cv-action-bar {
display: flex;
align-items: center;
@@ -29,24 +29,17 @@ import { MatCheckbox } from '@angular/material/checkbox';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { TasksService } from '../../tasks.service';
import { SharesService } from 'app/shares/shares.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { DecimalPipe } from '@angular/common';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
import { DatepickerBase } from 'app/shared/form-dialog/formfield/model/datepicker-base';
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
selector: 'mifosx-pending-account',
templateUrl: './pending-account.component.html',
styleUrls: [
'./pending-account.component.scss',
'../pending-share-accounts/pending-share-accounts.component.scss'
],
styleUrls: ['./pending-account.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
@@ -61,8 +54,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatHeaderRow,
MatRowDef,
MatRow,
MatIcon,
DecimalPipe
MatIcon
]
})
export class PendingAccountComponent {
@@ -72,7 +64,6 @@ export class PendingAccountComponent {
private dateUtils = inject(Dates);
private settingsService = inject(SettingsService);
private tasksService = inject(TasksService);
private sharesService = inject(SharesService);
dataSource: MatTableDataSource<any>;
selection: SelectionModel<any>;
@@ -90,26 +81,8 @@ export class PendingAccountComponent {
'status'
];
/** Share accounts awaiting approval - rendered as their own section on this same screen rather
* than a separate tab. Kept out of `dataSource`/`selection` (bulk-select + date-picker approve)
* since a share subscription is approved/rejected one at a time against today's business date,
* exactly as it always was on its own page - only where it's shown has changed. */
shareDataSource: MatTableDataSource<any>;
showShareData = false;
shareDisplayedColumns: string[] = [
'accountNo',
'clientName',
'productName',
'requestedShares',
'requestedAmount',
'resolution',
'submittedDate',
'actions'
];
constructor() {
this.route.data.subscribe((data: { pendingAccountsData: any; pendingShareAccountsData: any }) => {
this.route.data.subscribe((data: { pendingAccountsData: any }) => {
// status 100 = submitted and pending approval only; exclude terminated accounts
const items = (data.pendingAccountsData?.pageItems || []).filter(
(a: any) => a.status?.id === 100 && !a.terminateTrue
@@ -117,10 +90,6 @@ export class PendingAccountComponent {
this.showData = items.length > 0;
this.dataSource = new MatTableDataSource(items);
this.selection = new SelectionModel(true, []);
const shareItems = data.pendingShareAccountsData || [];
this.showShareData = shareItems.length > 0;
this.shareDataSource = new MatTableDataSource(shareItems);
});
}
@@ -314,92 +283,6 @@ export class PendingAccountComponent {
return date;
}
applyShareFilter(filterValue: string = '') {
this.shareDataSource.filter = filterValue.trim().toLowerCase();
}
/** A date from the share account API rendered readably, whether it arrives as a string or as
* Fineract's {@code [yyyy, m, d]} array. Kept separate from `formatDate()` above, which the
* normal-account table already relies on with its own date-array handling. */
formatShareDate(date: any): string {
if (!date) {
return '';
}
const d = Array.isArray(date) ? new Date(date[0], date[1] - 1, date[2]) : new Date(date);
return isNaN(d.getTime()) ? String(date) : d.toLocaleDateString('en-IN');
}
/** Opens the share account itself, for a checker who wants more than the row shows. */
viewShareAccount(account: any): void {
this.router.navigate([
'/clients',
account.clientId,
'shares-accounts',
account.shareAccountId
]);
}
approveShareAccount(account: any): void {
const dialogRef = this.dialog.open(ConfirmationDialogComponent, {
data: {
heading: 'Approve Share Account',
dialogContext:
`Approve share account "${account.accountNo}" for ${account.clientName} — ` +
`${account.requestedShares} shares at ${account.requestedAmount}?`,
type: 'basic'
}
});
dialogRef.afterClosed().subscribe((response: any) => {
if (response?.confirm) {
// The approval date is the business date rather than the checker's choice, same as this
// queue's original standalone screen: clearing a submission is a decision, not a backdated
// transaction, so a date field per row would be extra clicks for no benefit.
const data = {
approvedDate: this.dateUtils.formatDate(this.settingsService.businessDate, this.settingsService.dateFormat),
dateFormat: this.settingsService.dateFormat,
locale: this.settingsService.language.code
};
this.sharesService
.executeSharesAccountCommand(account.shareAccountId, 'approve', data)
.subscribe(() => this.reload());
}
});
}
rejectShareAccount(account: any): void {
const formfields: FormfieldBase[] = [
new InputBase({
controlName: 'rejectReason',
label: 'Reason for rejection',
value: '',
type: 'text',
required: false
})
];
const dialogRef = this.dialog.open(FormDialogComponent, {
data: {
title: `Reject Share Account ${account.accountNo}`,
formfields
}
});
dialogRef.afterClosed().subscribe((response: any) => {
if (response?.data) {
const data: any = {
rejectedDate: this.dateUtils.formatDate(this.settingsService.businessDate, this.settingsService.dateFormat),
dateFormat: this.settingsService.dateFormat,
locale: this.settingsService.language.code
};
const reason = response.data.value?.rejectReason?.trim();
if (reason) {
data.note = reason;
}
this.sharesService
.executeSharesAccountCommand(account.shareAccountId, 'reject', data)
.subscribe(() => this.reload());
}
});
}
reload() {
const url = this.router.url;
this.router
@@ -72,6 +72,15 @@
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
<span>Pending Account</span>
</a>
<a
class="ac-tab"
routerLink="./pending-share-accounts"
routerLinkActive="ac-tab--active"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
>
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
<span>Pending Share Accounts</span>
</a>
<a
class="ac-tab"
routerLink="./product-approval"
+1 -4
View File
@@ -108,10 +108,7 @@ const routes: Routes = [
component: PendingAccountComponent,
data: { title: 'Pending Account' },
resolve: {
pendingAccountsData: GetPendingAccounts,
// Share accounts awaiting approval now render inside this same screen instead of their
// own tab - reuses the same resolver the standalone route below still uses.
pendingShareAccountsData: GetPendingShareAccounts
pendingAccountsData: GetPendingAccounts
}
},
{
+1
View File
@@ -7,3 +7,4 @@
-->
<router-outlet></router-outlet>
<mifosx-loading-screen></mifosx-loading-screen>
+5
View File
@@ -296,6 +296,11 @@ export class WebAppComponent implements OnInit, OnDestroy {
});
});
}
// Dismiss initial application boot splash screen smoothly
if (typeof (window as any).hideAppSplash === 'function') {
(window as any).hideAppSplash();
}
}
ngOnDestroy() {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -21,7 +21,7 @@
// in the tenants table of the tenant-store database, or the platform rejects the request as an
// unknown tenant.
window["env"]["fineractPlatformTenantId"] = 'utkarshbank';
window["env"]["fineractPlatformTenantIds"] = 'utkarshbank,lincpay';
window["env"]["fineractPlatformTenantIds"] = 'utkarshbank,lincpay,utkarshnagari,testbank,humarwork,default,mybank';
window['env']['tenantLogoUrl'] = '';
window['env']['tenantLogoUrlDark'] = '';
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

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