Compare commits

..
45 Commits
Author SHA1 Message Date
nitesh 14c5f5070d Revert demo to pre-2026-08-06 state (rollback today's push)
Reverts all commits pushed to demo today (a5124ca3, 858c277a, a1a50ab8,
f72abb09, fcf66657) back to f8c532f7 as a single forward commit, without
rewriting history. All reverted content (Share Withdrawal/Closure feature,
TDS reports, KYC exception tracking, client-edit-email-dialog, form121 tab,
search-select) remains intact on dev and was verified identical before
this revert.
2026-08-06 13:39:40 +05:30
niteshandClaude Sonnet 5 fcf6665772 fix(nav): restore Loan Origination and Investment menus dropped by merge
The "Merge dev into demo" commit silently dropped both top-level nav
entries (desktop and mobile variants) along with their entire menu
panels — Loan Origination's mat-menu and the whole Investment mega-menu
(~150 lines) — because dev never had these menus, so the merge picked
dev's shell.component.html wholesale for this file instead of combining
both sides' additions. The underlying feature modules and routes were
untouched, so this restores only the shell wiring, copied verbatim from
the pre-merge demo revision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 13:19:24 +05:30
nitesh f72abb0921 Merge remote-tracking branch 'origin/demo' into demo 2026-08-06 13:14:26 +05:30
niteshandClaude Sonnet 5 a1a50ab8a3 chore: update version stamp
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 13:10:52 +05:30
niteshandClaude Sonnet 5 858c277ae3 chore: update version stamp
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 12:17:49 +05:30
Anshul Rathore a5124ca3e8 Merge dev into demo 2026-08-06 10:46:08 +05:30
Anshul Rathore 1a33f9d6ce Merge remote-tracking branch 'origin/dev' into dev
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-08-06 10:45:10 +05:30
Anshul Rathore 983c613b8e feat: update changes 2026-08-06 10:41:28 +05:30
nitesh ddb215decf Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev
Publish Image in Docker Hub / build (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-08-05 18:33:57 +05:30
niteshandClaude Sonnet 5 2cc8d979ba fix(shares): fix popup fixed-position bug, add Client Compliance card
backdrop-filter on the list-embedded review popup was making it a new
containing block for its own nested position:fixed dialogs (Validate,
Reject, Confirm-Submit), confining them to the popup's box instead of the
viewport — replaced the blur with a plain darker overlay. Also adds a
permanent Client Compliance card (KYC status, risk category, open AML
alerts) on the review screen so a manager can see it without opening the
Validate popup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 18:32:48 +05:30
keshav 569f17ef46 kyc exception at client creation time and some tds work
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-08-05 17:23:56 +05:30
niteshandClaude Sonnet 5 cc5cd4c6ab feat(shares): add Share Withdrawal maker-checker workflow with GL settlement
Tests / testRigor (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Clerk submits a governed share redemption request (shares, Board Resolution
No., Meeting Date, settlement Payment Type); Bank Manager reviews with a
live backend-validated checklist (account/GL state, not just submit-time
snapshot) before approving, which redeems the shares and settles the
linked savings account in one transaction. Adds a Passbook tab to the
Share Account view, a reusable search-select control, and a Savings
Product "Map Payment Types to Specific Funds" fund-source override (only
Loan Products had this before) so settlement can be GL-mapped correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 16:55:21 +05:30
keshav 504d7ee7f6 Merge branch 'demo' of https://git.lincpay.tech/CBSNexora/cbsnexora into demo 2026-08-03 10:51:53 +05:30
keshav 26091c68b0 fix(accounting): prioritize specific Fineract error messages when AML marking fails 2026-08-03 10:51:07 +05:30
niteshandClaude Sonnet 5 f8c532f74a fix(tasks): wrap Approval Center tabs instead of scrolling behind pagination arrows
Replace mat-tab-nav-bar with a plain wrapping pill nav so all 16 approval sections stay
visible and one click away, instead of being hidden behind Material's prev/next scroll
arrows on a single-row strip.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 10:36:13 +05:30
keshav 44b41e504a Merge branch 'demo' of https://git.lincpay.tech/CBSNexora/cbsnexora into demo 2026-08-03 10:32:31 +05:30
keshav e8d91c8f60 feat(accounting): add AML manual referral and marking to general ledger entries 2026-08-03 10:31:32 +05:30
nitesh 8dd9767bdf Merge branch 'demo' of https://git.lincpay.tech/CBSNexora/cbsnexora into demo 2026-08-02 21:48:25 +05:30
niteshandClaude Sonnet 5 c868beb344 chore(npa): rename NPA Interest report/menu item to NPA Report
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 21:44:46 +05:30
keshav b43ed87771 risk review report with on which aml rule 2026-08-02 21:40:34 +05:30
niteshandClaude Sonnet 5 ebe67cc410 feat(investment,npa): RBI-grounded targets, GL-tracked interest posting, date/search pickers
- Correct Investment portfolio allocation and Loan Origination product-mix targets against
  RBI's UCB SLR/Non-SLR framework and PSL circulars, with a Target Basis column distinguishing
  RBI-mandated vs board-policy rows (Excel export included).
- Expand Investment holdings dataset with more near-term maturities so the Due-Date Diary
  reflects a realistic spread across FD/Govt Security/Non-SLR/Bond.
- Add From/To period-based Interest Calculation & Post (with GL selection for both posting and
  receiving) across the Investment Interest module, FD Entry and Bond Entry screens; fix a bug
  where a posted period kept re-appearing as due since accrual wasn't tracked per-holding.
- Add a reusable DD-MM-YYYY date-picker component and roll it out across all Investment date
  fields.
- NPA Summary Report: drop SMA-0/1/2 from the stage-wise table (kept in Day-wise Overdue only).
- NPA Recovery Report: add Account Type / NPA Category / Target-vs-Actual columns, a shared
  Account Type filter driving both the account picker and the register, and a searchable
  (fixed-position, not clipped by card overflow) account select for long lists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 19:26:19 +05:30
nitesh d7f5bfe5f5 feat(fiu): add Head Office / Branch-wise scoping; remove reconciliation nav dots
FIU: adds a branch selector (Head Office sees all/drills into one branch,
a branch-level login is locked to its own branch) scoping the CTR/STR tabs,
dashboard KPIs and header. A FIU_BRANCH_CODE_TO_CANONICAL map bridges the
module's internal FINnet branch codes to the app-wide BranchMaster codes.
Live backend-driven AML alerts/cases are unchanged (no branch field to scope by).

Shell: removes the "working nav" green-dot indicator from the Reconciliation
mega-menu's 17 report links per request.
2026-08-02 16:06:03 +05:30
nitesh be6103e0b3 Merge branch 'demo' of https://git.lincpay.tech/CBSNexora/cbsnexora into demo 2026-08-02 14:23:46 +05:30
nitesh 5cd9998cfc feat(loan-origination): add full Loan Origination Sanctioning workflow module
New standalone module modelling the pre-disbursement pipeline: Channel intake
(Branch/BC/Self-Apply) -> KYC/OVD & e-KYC fingerprint -> CIC/Credit Bureau
check -> Field Investigation -> Personal Discussion & declaration -> Loan
Committee -> Risk Approval -> Legal Approval -> Customer Signing -> Cheque/ACH
-> Post-Disbursement Check -> Disbursement, per RBI KYC Master Direction, the
mandatory CIC check, and co-operative-bank share-membership rules.

Includes a 12-stage stepper with real approve/reject actions and a date-wise
audit trail, CIC upload + credit-score check, Field Inquiry push + Agent
Report upload, Committee "Reports Required" / Risk "Verification Checklist",
Head-Office-vs-branch scoping, a Pipeline screen (filters, New Application
intake, Channel-wise/BC-wise/Share-Member reports, Disbursement Target vs
Actual, Export to Excel), and a full EMI repayment schedule once disbursed.
2026-08-02 14:22:32 +05:30
keshav 0ca6842bde Merge branch 'demo' of https://git.lincpay.tech/CBSNexora/cbsnexora into demo 2026-08-02 11:49:32 +05:30
keshav 3030e2f34e form 91/98 report, min max value update in AML rule and whihc rule apply day, week, month and some more updates 2026-08-02 11:46:40 +05:30
nitesh b4608cbbe4 feat(npa): add Recovery Management System reports + Recovery Journey popup
Adds loan-type-aware recovery tracking (EMI/Overdrawn/Bullet) with a Recovery
Management Dashboard, Day-wise Overdue/Overdrawn reports, an Agriculture/Gold
market-value report, and a security-value exception check — all derived from
new repaymentType/emiAmount/drawingPower/maturityDate/collateralMarketValue
fields on LoanAccount, wired into Export to Excel and the NPA mega-menu.

Also replaces the always-visible Recovery/Collection Journey card on the
Account Detail Report with a "Recovery Journey" popup: a date-led, icon-coded
timeline of every SMS/call/visit/notice and payment run against an account.
2026-08-02 11:33:01 +05:30
nitesh 27cdaf7fc2 feat(npa): add Undebited Interest Recovery Report, fix note text width
Adds a 10th NPA report tracking memorandum "undebited interest" per IRAC
norms (accrued vs cash-recovered vs balance), wired into Export to Excel
and the NPA mega-menu. Also fixes a narrow-width text wrap on the report's
description note (new .npa-report-note class overriding pg-sub's 560px cap).
2026-08-02 08:34:03 +05:30
nitesh 90beea64cb feat(npa): add Export to Excel across all 9 NPA reports
Every report screen (Interest, Status, Recovery, Summary, Account Detail,
Movement, Provision, Probable NPA, SMA) now has an Export to Excel button
that builds a workbook from the same derived getters the on-screen tables
use, reusing the shared cashier-export.ts builder.
2026-08-01 20:58:42 +05:30
nitesh 1317a93838 feat(cashier): full double-entry journal view + Excel export for Cash/Clearing/Transfer Summary
Transaction Details now renders each voucher as a paired Dr/Cr journal entry with a
derived contra account and a Balanced status chip, so both sides of a transaction are
visible at a glance for reconciliation. Adds a shared cashier-export.ts (mirrors the
Investment module's exporter) and an Export to Excel button producing a Summary +
Transaction Details workbook, plus a Branch Breakup sheet for Head Office consolidated view.
2026-08-01 20:32:31 +05:30
nitesh 0832017508 feat(investment,reconciliation): add Export to Excel across Investment, fix export popup
Adds a shared Export to Excel utility (investment-export.ts) and wires it
into every listing/report screen in the Investment module — Dashboard
(break-up + allocation), Reports (all ~20 report shapes), Interest (per
active tab), the FD/Bond/Share Holdings registers, and Masters (per active
master tab). Entry/transaction forms intentionally have no export button.
No sign-off is included here, matching the request to keep that feature
reconciliation-only.

Also fixes the Reconciliation export flow: the Prepared/Checked/Approved
sign-off is now an optional one-off popup shown when "Export to Excel" is
clicked (fields are blank by default and downloading works whether or not
they're filled), replacing the earlier always-visible sign-off card. Also
fixes three reconciliation reports (Sundry Dr/Cr, NEFT/RTGS, UDIR) whose
labels contain "/" — an Excel-forbidden worksheet-name character — which
was silently aborting their export.
2026-08-01 19:20:53 +05:30
nitesh 8ac4010967 feat(investment): add portfolio allocation targets and interest receivable/post/receive lifecycle
Adds a Portfolio Allocation (Target vs Actual %) section to the Investment
Dashboard, and reworks Investment Interest into the full accrual lifecycle:
Interest Receivable, FD/Govt./Bond Interest Calculation & Post, and Interest
Receive, plus the existing registers.

Interest Calculation & Post now tracks cumulative posted/received amounts
per holding instead of recomputing full accrued interest on every click
(which previously double-counted on repeat posting). Holdings classified
Non-Performing (Sub-Standard/Doubtful/Loss) are excluded from accrual
posting per RBI norms — their interest accrues in memo/suspense only and is
recognised solely on actual cash receipt via Interest Receive.

Also bumps the production initial-bundle budget in angular.json (13/14mb ->
15/17mb) since the growing set of eagerly-loaded feature modules (Investment,
NPA, Reconciliation, GL Reports, OSS Returns) pushed the build past the old
limit.
2026-08-01 17:56:44 +05:30
nitesh 1c125d97c5 Merge branch 'demo' of https://git.lincpay.tech/CBSNexora/cbsnexora into demo 2026-08-01 13:15:57 +05:30
nitesh c935e2a62a feat(reconciliation): build full 16-screen Reconciliation module
Wires every item in the Reconciliation mega-menu (Core & Books, Bank &
Clearing, Digital & Payments, Instruments & Members, Compliance &
Financials) to a working screen, mirroring the NPA module's pattern: one
route-param-driven component over a shared two-sided (Side A vs Side B)
matching model, with pagination and a "Mark as Reconciled" action on every
screen.

Access is split by real-world scope: activity that happens at a branch
(vouchers, cheques, digital transactions, share members, fixed assets) is
branch-wise with the same Head-Office/branch login behavior as NPA; activity
with no branch dimension in real banking (GL/P&L consolidation, treasury's
own bank accounts, statutory tax filing, vendor procurement, inter-branch
netting) is Head-Office-only, with branch logins shown a restricted notice
instead of the data.

Also adds the .menu-item-live "working" indicator to mat-menu-item buttons
(same convention as .gl-link--live) and applies it across the reconciliation
menu and the pre-existing DD/Pay Order Reconciliation link.
2026-08-01 13:15:17 +05:30
keshav 2a9df828ca fix: update General Ledger toolbar menu links with routerLink 2026-08-01 12:01:55 +05:30
keshav bd753f4983 oss file updated, profitl loss as per the day month and also with date select 2026-08-01 11:46:11 +05:30
nitesh 4261f9659e feat(npa): add branch-wise NPA module with realistic data, pagination and recovery actions
Adds a full NPA (Non-Performing Assets) module mirroring the legacy CBS NPA
mega-menu: NPA Reports, NPA Analysis, and NPA Classification screens over a
640-account loan book classified per RBI IRAC norms (Standard through
Loss). Same Head-Office/branch-wise access pattern as the Cash/Clearing/
Transfer summary, 20-row pagination on every large table, and a Recovery
Action workflow (demand notice, SARFAESI, suit filing, OTS) on the Account
Detail screen.

Fixes several data-generation bugs so every account stays internally
consistent regardless of when it's viewed: disbursement date can no longer
predate the account's own overdue history, last-payment date now reflects
the most recent recovery entry, the quarter-ago snapshot can't imply more
overdue days than the loan's age allowed, and provision held is capped at
the account's outstanding balance.

Also fixes a text-overflow/dropdown-arrow overlap on long select options
(Investment + NPA shared form styles), and marks the GL "Financial
Statements" menu items that are already wired to a working dialog.
2026-08-01 11:18:02 +05:30
nitesh 63ac900e42 feat(investment): add Treasury Investment module (FD, Govt Sec/Non-SLR/Bond/MF, Share)
Adds full frontend-only Investment module mirroring the legacy AutoBank CBS
Investment menu: Dashboard, FD/Bond/Share entry screens (purchase, interest,
sell/redeem, write-off, amortize and more), 10 master screens, an Interest
calculation/register screen, and ~21 reports. All figures are derived from a
single holdings/transactions dataset so entry, interest, and reports can
never drift out of sync. Wires the new "Investment" mega-menu into the shell
and routes the previously inert Investment stub buttons.
2026-08-01 09:17:06 +05:30
niteshandClaude Sonnet 5 fd28788d23 fix(cashier): realistic branch-aware account numbers, dynamic date tags in summary report
Account No now always holds a genuine reference for its transaction type
(customer account, GL/facility code, IFSC, DD/PO/TDS/GST/cheque ref, or
UTR/RRN/UMRN for RTGS/NEFT/UPI/ATM/NACH) instead of a plain description.
Branch 2's view derives its own valid-format account numbers and IFSC
codes by shifting the numeric segment, rather than tagging a "BR2-" prefix
onto Branch 1's values, which looked fake for standardized formats.

Voucher/reference numbers' baked-in "2607" (Jul 2026) tag is now derived
from the real current date at render time, so it won't go stale next month.

feat(clients): auto-calculate Monthly/Annual Income and Turnover

Typing either the monthly or annual figure now fills in the other at a 12x
ratio (rounded to 2 decimals, no trailing ".00" on whole numbers). Guarded
against firing during prefillForEdit()'s initial load of an existing
client's independently-saved values, so editing doesn't silently overwrite
historical data that wasn't an exact 12x match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 17:24:57 +05:30
keshav b9b37397fe Merge remote-tracking branch 'origin/demo' into demo 2026-07-31 15:10:42 +05:30
keshav f2525080c8 static update 2026-07-31 15:09:18 +05:30
Anshul Rathore a982083b14 chore: stop tracking auto-generated .env.ts file 2026-07-31 15:08:35 +05:30
nitesh 43e3c546a7 Merge remote-tracking branch 'origin/dev' into demo 2026-07-31 14:47:03 +05:30
nitesh b68b25f789 Merge remote-tracking branch 'origin/uate' into demo 2026-07-31 14:46:38 +05:30
niteshandClaude Sonnet 5 1dc378c2dd feat(cashier): add Cash/Clearing/Transfer summary report with branch-wise drill-down
Adds a dummy-data summary report (Opening/Credit/Debit/Closing) under Sub
Ledger > Cash / Clearing / Transfer, with Cash, Transfer (GL-wise), Clearing
and Digital (RTGS/NEFT/UPI/ATM/NACH) tabs plus a transaction-details drill
down. Credit/Debit/Closing are always derived from the transaction lines so
the summary and detail views can never drift apart.

Head Office logins (officeId 1 or office name containing "head office") get
a branch switcher across two demo branches plus a consolidated view with a
branch-wise break-up; branch-level logins see only their own office's data.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 13:39:13 +05:30
128 changed files with 49325 additions and 1136 deletions
+2 -2
View File
@@ -69,8 +69,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "13mb",
"maximumError": "14mb"
"maximumWarning": "15mb",
"maximumError": "17mb"
},
{
"type": "anyComponentStyle",
@@ -0,0 +1,70 @@
<!--
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/.
-->
<h2 class="mk-title" [class.mk-title--str]="isEscalation">
<fa-icon [icon]="isEscalation ? 'exclamation-triangle' : 'check-circle'"></fa-icon>
{{ isEscalation ? 'Mark as Suspicious' : 'Mark as Whitelisted' }}
</h2>
<mat-dialog-content>
<div class="mk-entry">
<div>
<label>Journal entry</label><span class="mono">#{{ data.journalEntryId }}</span>
</div>
<div>
<label>Transaction</label><span class="mono">{{ data.transactionId }}</span>
</div>
<div>
<label>Date</label><span>{{ data.transactionDate }}</span>
</div>
<div>
<label>Amount</label><span class="mono">&#8377;{{ data.amount | number: '1.2-2' }}</span>
</div>
<div class="wide">
<label>GL account</label><span>{{ data.glAccountName }}</span>
</div>
</div>
@if (isEscalation) {
<p class="mk-warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
This raises an AML alert against the customer behind this entry and escalates it. An STR case opens for the
Principal Officer with the seven working day FIU-IND clock running. It cannot be undone from this screen.
</p>
} @else {
<p class="mk-note">
Records the entry as reviewed and cleared. No report is filed and the customer's risk rating is untouched.
</p>
}
<label class="mk-field">
<span>Reviewer notes {{ isEscalation ? '(required)' : '(optional)' }}</span>
<textarea
rows="4"
[(ngModel)]="notes"
[placeholder]="
isEscalation
? 'Why is this entry suspicious? What did you check?'
: 'What satisfied you that this entry is in order?'
"
></textarea>
</label>
</mat-dialog-content>
<mat-dialog-actions class="mk-actions">
<button class="mk-btn" (click)="dialogRef.close()">Cancel</button>
<button
class="mk-btn"
[class.mk-btn--str]="isEscalation"
[class.mk-btn--ok]="!isEscalation"
[disabled]="!canSubmit"
(click)="confirm()"
>
{{ isEscalation ? 'Escalate to STR' : 'Whitelist entry' }}
</button>
</mat-dialog-actions>
@@ -0,0 +1,165 @@
/**
* 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 {
--mk-g100: #f1f5f9;
--mk-g200: #e2e8f0;
--mk-g300: #cbd5e1;
--mk-g500: #64748b;
--mk-g700: #334155;
--mk-g900: #0f172a;
--mk-green: #15803d;
--mk-red: #c0392b;
--mk-amber-l: #fef3c7;
display: block;
font-family: 'DM Sans', system-ui, sans-serif;
color: var(--mk-g900);
min-width: 460px;
}
.mk-title {
display: flex;
align-items: center;
gap: 9px;
margin: 0;
padding: 18px 22px 10px;
font-size: 16px;
font-weight: 700;
color: var(--mk-green);
&--str {
color: var(--mk-red);
}
}
.mk-entry {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 18px;
padding: 11px 14px;
background: #fff;
border: 1px solid var(--mk-g200);
border-radius: 8px;
margin-bottom: 12px;
> div {
display: flex;
flex-direction: column;
}
.wide {
grid-column: span 2;
}
label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--mk-g500);
}
span {
font-size: 12.5px;
}
}
.mk-warn {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
color: #78350f;
background: var(--mk-amber-l);
border-radius: 7px;
padding: 9px 12px;
margin: 0 0 12px;
}
.mk-note {
font-size: 12px;
color: var(--mk-g500);
margin: 0 0 12px;
}
.mk-field {
display: flex;
flex-direction: column;
gap: 4px;
span {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--mk-g500);
}
textarea {
padding: 9px 11px;
border: 1px solid var(--mk-g300);
border-radius: 7px;
font-size: 13px;
font-family: inherit;
resize: vertical;
&:focus {
outline: none;
border-color: var(--mk-g700);
}
}
}
.mk-actions {
display: flex;
justify-content: flex-end;
gap: 9px;
padding: 8px 22px 18px;
}
.mk-btn {
padding: 8px 16px;
border: 1px solid var(--mk-g300);
background: #fff;
border-radius: 8px;
font-size: 12.5px;
font-weight: 600;
color: var(--mk-g700);
cursor: pointer;
font-family: inherit;
&:hover:not(:disabled) {
background: var(--mk-g100);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&--ok {
background: var(--mk-green);
border-color: var(--mk-green);
color: #fff;
&:hover:not(:disabled) {
background: #12692f;
}
}
&--str {
background: var(--mk-red);
border-color: var(--mk-red);
color: #fff;
&:hover:not(:disabled) {
background: #a5301f;
}
}
}
@@ -0,0 +1,63 @@
/**
* 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/.
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogContent, MatDialogActions } from '@angular/material/dialog';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
export interface MarkEntryDialogData {
disposition: 'WHITELIST' | 'ESCALATE';
journalEntryId: number;
transactionId: string;
amount: number;
glAccountName: string;
transactionDate: string;
}
/**
* Captures the reviewer's reason before a ledger entry is marked.
*
* Notes are not optional on an escalation: the Principal Officer picks the case up
* with nothing else to go on, and an STR filed on an unexplained referral is not
* defensible. A whitelist may be recorded without them.
*/
@Component({
selector: 'mifosx-mark-entry-dialog',
templateUrl: './mark-entry-dialog.component.html',
styleUrls: ['./mark-entry-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent,
MatDialogContent,
MatDialogActions
]
})
export class MarkEntryDialogComponent {
dialogRef = inject<MatDialogRef<MarkEntryDialogComponent>>(MatDialogRef);
data = inject<MarkEntryDialogData>(MAT_DIALOG_DATA);
notes = '';
get isEscalation(): boolean {
return this.data.disposition === 'ESCALATE';
}
get canSubmit(): boolean {
return !this.isEscalation || this.notes.trim().length > 0;
}
confirm(): void {
if (this.canSubmit) {
this.dialogRef.close(this.notes.trim());
}
}
}
@@ -535,6 +535,14 @@
</div>
</div>
@if (markError) {
<div class="gl-mark-error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>{{ markError }}</span>
<button class="gl-mark-error__close" (click)="markError = ''">&times;</button>
</div>
}
<div class="gl-table-shell">
<mat-table [dataSource]="dataSource" matSort class="gl-mat-table">
<!-- #ID -->
@@ -692,6 +700,50 @@
</mat-cell>
</ng-container>
<!-- AML review — whitelist or refer the entry to STR -->
<ng-container matColumnDef="amlMark">
<mat-header-cell *matHeaderCellDef class="gl-th">AML Review</mat-header-cell>
<mat-cell *matCellDef="let e" class="gl-td">
@if (markOf(e); as mark) {
<span
class="gl-mark"
[class.gl-mark--str]="mark.disposition === 'ESCALATE'"
[matTooltip]="
(mark.disposition === 'ESCALATE'
? 'Escalated to STR' + (mark.strCaseId ? ' — case #' + mark.strCaseId : '')
: 'Whitelisted') +
(mark.reviewedBy ? ' by ' + mark.reviewedBy : '') +
(mark.notes ? ' — ' + mark.notes : '')
"
>
<fa-icon [icon]="mark.disposition === 'ESCALATE' ? 'exclamation-triangle' : 'check-circle'"></fa-icon>
{{ mark.disposition === 'ESCALATE' ? 'Suspicious' : 'Whitelisted' }}
</span>
} @else if (isMarking(e)) {
<span class="gl-mark gl-mark--busy">Saving…</span>
} @else if (canMark(e)) {
<span class="gl-mark-actions">
<button
class="gl-mark-btn gl-mark-btn--ok"
matTooltip="Reviewed and in order — clear this entry"
(click)="openMarkDialog(e, 'WHITELIST')"
>
<fa-icon icon="check-circle"></fa-icon> Whitelist
</button>
<button
class="gl-mark-btn gl-mark-btn--str"
matTooltip="Refer to the Principal Officer as an STR case"
(click)="openMarkDialog(e, 'ESCALATE')"
>
<fa-icon icon="exclamation-triangle"></fa-icon> Suspicious
</button>
</span>
} @else {
<span class="gl-mark gl-mark--na" [matTooltip]="markBlockedReason(e)">—</span>
}
</mat-cell>
</ng-container>
<!-- Actions -->
<ng-container matColumnDef="actions">
<mat-header-cell *matHeaderCellDef class="gl-th"></mat-header-cell>
@@ -1328,6 +1328,17 @@
flex: 0 0 130px;
}
// Wide enough to hold both action buttons on one line. Every other column
// declares a width; without one this collapsed and clipped its contents.
::ng-deep .mat-column-amlMark {
width: 184px;
min-width: 184px;
flex: 0 0 184px;
// Keeps the "AML REVIEW" heading on one line instead of clipping it.
white-space: nowrap;
overflow: visible;
}
::ng-deep .mat-column-actions {
width: 50px;
min-width: 50px;
@@ -1556,6 +1567,112 @@
}
}
// ── AML review column ─────────────────────────────────────────────────────────
// Never wrap — a wrapped second button used to be clipped by the row height.
.gl-mark-actions {
display: flex;
align-items: center;
gap: 5px;
flex-wrap: nowrap;
width: 100%;
}
.gl-mark-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 4px 8px;
border: 1px solid var(--c-border);
background: var(--c-surface);
border-radius: 6px;
font-size: 0.62rem;
font-weight: 700;
line-height: 1.2;
color: var(--c-text-2);
cursor: pointer;
font-family: inherit;
white-space: nowrap;
flex: 1 1 auto;
transition: all var(--transition);
// Tinted at rest, not only on hover — these have to be findable in a dense
// table without the reviewer hunting for them.
&--ok {
border-color: rgb(16 185 129 / 45%);
background: var(--c-cr-bg);
color: #047857;
&:hover {
border-color: var(--c-cr);
background: #d1fae5;
}
}
&--str {
border-color: rgb(239 68 68 / 45%);
background: var(--c-dr-bg);
color: #b91c1c;
&:hover {
border-color: var(--c-dr);
background: #fee2e2;
}
}
}
.gl-mark {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 9px;
border-radius: 999px;
font-size: 0.62rem;
font-weight: 700;
white-space: nowrap;
background: var(--c-cr-bg);
color: #047857;
&--str {
background: var(--c-dr-bg);
color: #b91c1c;
}
&--busy {
background: #f1f5f9;
color: var(--c-text-3);
}
&--na {
background: transparent;
color: var(--c-text-3);
cursor: help;
}
}
.gl-mark-error {
display: flex;
align-items: center;
gap: 9px;
margin: 0 0 10px;
padding: 9px 14px;
border: 1px solid #fecaca;
background: var(--c-dr-bg);
border-radius: 8px;
font-size: 0.75rem;
color: #b91c1c;
&__close {
margin-left: auto;
border: 0;
background: transparent;
font-size: 1.1rem;
line-height: 1;
color: inherit;
cursor: pointer;
}
}
// ── Empty state ───────────────────────────────────────────────────────────────
.gl-empty {
display: flex;
@@ -41,9 +41,20 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatCardModule } from '@angular/material/card';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatIconModule } from '@angular/material/icon';
import { MatDialog } from '@angular/material/dialog';
import { AmlService } from 'app/aml/aml.service';
import { MarkEntryDialogComponent, MarkEntryDialogData } from './mark-entry-dialog.component';
const STATE_KEY = 'gl_ledger_filter_state';
/** How a ledger entry has been marked by a reviewer, keyed on journal entry id. */
interface EntryMark {
disposition: 'WHITELIST' | 'ESCALATE';
strCaseId: number | null;
reviewedBy: string;
notes: string;
}
@Component({
selector: 'mifosx-search-journal-entry',
standalone: true,
@@ -87,6 +98,8 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private route = inject(ActivatedRoute);
private amlService = inject(AmlService);
private dialog = inject(MatDialog);
// ── Misc ──────────────────────────────────────────────────────────────────
minDate = new Date(2000, 0, 1);
@@ -129,6 +142,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
'manualEntry',
'createdByUserName',
'submittedOnDate',
'amlMark',
'actions'
];
dayWiseColumns = [
@@ -157,6 +171,14 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
dayWiseData: any[] = [];
glAccountInfo: any = null;
// ── AML marking ───────────────────────────────────────────────────────────
/** Marks already recorded, by journal entry id. */
private marks = new Map<number, EntryMark>();
/** Entry ids with a request in flight, so a row cannot be double-submitted. */
private marking = new Set<number>();
markError = '';
// ── UI state ──────────────────────────────────────────────────────────────
activeQuickFilter = '';
activeFiltersCount = 0;
@@ -212,6 +234,103 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
this.setFilteredOffices();
this.getJournalEntries();
this.loadLedgerSummary();
this.loadMarks();
}
// ── AML marking ───────────────────────────────────────────────────────────
/**
* Marks are loaded once for the whole ledger rather than per page, because the
* user pages and re-filters constantly and the set is small — only entries a
* reviewer has actually touched.
*/
private loadMarks(): void {
this.amlService.getManualReferrals().subscribe({
next: (rows) => {
this.marks = new Map(
(rows || []).map((r: any) => [
r.journalEntryId,
{
disposition: r.disposition === 'ESCALATE' ? 'ESCALATE' : 'WHITELIST',
strCaseId: r.strCaseId ?? null,
reviewedBy: r.reviewedByUsername || '',
notes: r.reviewNotes || ''
} as EntryMark
])
);
},
// A ledger that cannot reach the AML service still has to render.
error: () => (this.marks = new Map())
});
}
markOf(entry: any): EntryMark | null {
return this.marks.get(entry?.id) ?? null;
}
isMarking(entry: any): boolean {
return this.marking.has(entry?.id);
}
/**
* An alert needs a customer, and a journal entry only reaches one through the
* savings, loan or client transaction that produced it. A purely manual GL
* posting reaches none, so it cannot be referred — the server enforces this too.
*/
canMark(entry: any): boolean {
return !!entry && !this.markOf(entry) && !!(entry.savingsAccountNo || entry.entityId);
}
markBlockedReason(entry: any): string {
if (this.markOf(entry)) {
return 'Already reviewed — a ledger entry is marked once';
}
return 'No customer behind this entry, so it cannot be referred';
}
openMarkDialog(entry: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
if (!this.canMark(entry) || this.isMarking(entry)) {
return;
}
const data: MarkEntryDialogData = {
disposition,
journalEntryId: entry.id,
transactionId: entry.transactionId,
amount: entry.amount,
glAccountName: `${entry.glAccountCode} — ${entry.glAccountName}`,
transactionDate: this.dateUtils.formatDate(entry.transactionDate, this.settingsService.dateFormat)
};
this.dialog
.open(MarkEntryDialogComponent, { data, width: '520px', autoFocus: false })
.afterClosed()
.subscribe((notes: string | undefined) => {
if (notes !== undefined) {
this.submitMark(entry, disposition, notes);
}
});
}
private submitMark(entry: any, disposition: 'WHITELIST' | 'ESCALATE', notes: string): void {
this.markError = '';
this.marking.add(entry.id);
this.amlService.referJournalEntry(entry.id, disposition, notes).subscribe({
next: (res: any) => {
this.marking.delete(entry.id);
this.marks.set(entry.id, {
disposition,
strCaseId: res?.strCaseId ?? null,
reviewedBy: res?.reviewedByUsername || '',
notes
});
},
error: (err: any) => {
this.marking.delete(entry.id);
this.markError =
err?.error?.defaultUserMessage || err?.error?.errors?.[0]?.defaultUserMessage || 'Could not mark this entry.';
}
});
}
ngOnDestroy() {
+22
View File
@@ -34,6 +34,28 @@ export class AmlService {
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
}
// ═══ Manual referrals from the general ledger ═══
/**
* Ledger entries a user has already marked, keyed on `journalEntryId`.
*
* Separate from `getAlerts()` because these are not engine alerts — they carry the
* journal entry id rather than a savings or loan transaction id, so the ledger
* screen can match them to the rows it is showing.
*/
getManualReferrals(): Observable<any[]> {
return this.http.get<any[]>('/aml/alerts/manual-referrals');
}
/**
* Marks a ledger entry. WHITELIST clears it with no report; ESCALATE opens an STR
* case for the Principal Officer, the same road an escalated engine alert takes.
* The entry must have a customer behind it or the server rejects it.
*/
referJournalEntry(journalEntryId: number, disposition: 'WHITELIST' | 'ESCALATE', notes: string): Observable<any> {
return this.http.post('/aml/alerts/manual-referral', { journalEntryId, disposition, notes });
}
// ═══ STR cases (Principal Officer) ═══
getStrCases(): Observable<any[]> {
+10
View File
@@ -64,7 +64,12 @@ import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
import { DeafModule } from './deaf/deaf.module';
import { DicgcModule } from './dicgc/dicgc.module';
import { FiuModule } from './fiu/fiu.module';
import { GlReportsModule } from './gl-reports/gl-reports.module';
import { SftModule } from './sft/sft.module';
import { InvestmentModule } from './investment/investment.module';
import { NpaModule } from './npa/npa.module';
import { ReconciliationModule } from './reconciliation/reconciliation.module';
import { LoanOriginationModule } from './loan-origination/loan-origination.module';
import { DigitalModule } from './digital/digital.module';
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
import { AccountsModule } from './accounts/accounts.module';
@@ -171,7 +176,12 @@ export function HttpLoaderFactory(http: HttpClient) {
DeafModule,
DicgcModule,
FiuModule,
GlReportsModule,
SftModule,
InvestmentModule,
NpaModule,
ReconciliationModule,
LoanOriginationModule,
DigitalModule,
LetterMgmtModule,
AccountsModule,
@@ -0,0 +1,292 @@
<!--
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="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">SUB LEDGER · TRANSACTIONS · {{ scopeName | uppercase }}</div>
<h1 class="pg-title">Cash / Clearing / Transfer - Summary Report</h1>
<p class="pg-sub">
Opening, credit, debit and closing position across Cash, Transfer, Clearing and Digital channels as on
{{ asOnDate }}.
</p>
</div>
<div class="pg-toolbar">
@if (isHeadOffice) {
<label class="pg-field">
<span>Branch</span>
<select class="pg-select" [ngModel]="selectedBranchCode" (ngModelChange)="setBranch($event)">
<option value="ALL">All Branches (Consolidated)</option>
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
}
</select>
</label>
}
<button type="button" class="pg-btn" [class.pg-btn--active]="showDetails" (click)="toggleDetails()">
{{ showDetails ? 'Summary View' : 'Transaction Details' }}
</button>
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
</div>
</header>
<div class="cctr-grand-summary">
<div class="cctr-grand-summary__item">
<span class="cctr-grand-summary__label">Opening</span>
<span class="cctr-grand-summary__value">₹{{ grandOpening | number: '1.2-2' }}</span>
</div>
<div class="cctr-grand-summary__item">
<span class="cctr-grand-summary__label">Credit</span>
<span class="cctr-grand-summary__value col-in">₹{{ grandCredit | number: '1.2-2' }}</span>
</div>
<div class="cctr-grand-summary__item">
<span class="cctr-grand-summary__label">Debit</span>
<span class="cctr-grand-summary__value col-out">₹{{ grandDebit | number: '1.2-2' }}</span>
</div>
<div class="cctr-grand-summary__item">
<span class="cctr-grand-summary__label">Closing</span>
<span class="cctr-grand-summary__value">₹{{ grandClosing | number: '1.2-2' }}</span>
</div>
</div>
@if (isHeadOffice && selectedBranchCode === 'ALL') {
<div class="pg-card">
<div class="pos-section-label">Branch-wise Break-up — click a branch to drill in</div>
<table class="pg-table">
<thead>
<tr>
<th>Branch</th>
<th class="num">Opening</th>
<th class="num col-in">Credit</th>
<th class="num col-out">Debit</th>
<th class="num col-net">Closing</th>
</tr>
</thead>
<tbody>
@for (b of branchBreakup; track b.code) {
<tr class="cctr-branch-row" (click)="setBranch(b.code)">
<td>{{ b.name }}</td>
<td class="num">₹{{ b.opening | number: '1.2-2' }}</td>
<td class="num col-in">₹{{ b.credit | number: '1.2-2' }}</td>
<td class="num col-out">₹{{ b.debit | number: '1.2-2' }}</td>
<td class="num col-net">₹{{ b.closing | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td>All Branches</td>
<td class="num">₹{{ grandOpening | number: '1.2-2' }}</td>
<td class="num">₹{{ grandCredit | number: '1.2-2' }}</td>
<td class="num">₹{{ grandDebit | number: '1.2-2' }}</td>
<td class="num">₹{{ grandClosing | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
<div class="cctr-tabs">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'cash'"
(click)="setTab('cash')"
>
Cash
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'transfer'"
(click)="setTab('transfer')"
>
Transfer
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'clearing'"
(click)="setTab('clearing')"
>
Clearing
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'digital'"
(click)="setTab('digital')"
>
Digital
</button>
</div>
<!-- Summary (Opening | Credit | Debit | Closing) -->
@if (!showDetails) {
<div class="pg-card">
@if (activeTab === 'digital') {
<div class="pos-section-label">RTGS · NEFT · UPI · ATM · NACH — Overall Position</div>
}
<table class="pg-table">
<thead>
<tr>
@if (activeTab !== 'digital') {
<th>GL Code</th>
}
<th>Particulars</th>
<th class="num">Opening</th>
<th class="num col-in">Credit</th>
<th class="num col-out">Debit</th>
<th class="num col-net">Closing</th>
</tr>
</thead>
<tbody>
@for (r of rowsForActiveTab; track r.code) {
<tr>
@if (activeTab !== 'digital') {
<td>
<span class="pg-chip">{{ r.code }}</span>
</td>
}
<td>{{ r.name }}</td>
<td class="num">₹{{ r.opening | number: '1.2-2' }}</td>
<td class="num col-in">₹{{ rowCredit(r, txnsForActiveTab) | number: '1.2-2' }}</td>
<td class="num col-out">₹{{ rowDebit(r, txnsForActiveTab) | number: '1.2-2' }}</td>
<td class="num col-net">₹{{ closing(r, txnsForActiveTab) | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td [attr.colspan]="activeTab !== 'digital' ? 2 : 1">Total</td>
<td class="num">₹{{ total(rowsForActiveTab, txnsForActiveTab, 'opening') | number: '1.2-2' }}</td>
<td class="num">₹{{ total(rowsForActiveTab, txnsForActiveTab, 'credit') | number: '1.2-2' }}</td>
<td class="num">₹{{ total(rowsForActiveTab, txnsForActiveTab, 'debit') | number: '1.2-2' }}</td>
<td class="num">₹{{ totalClosing(rowsForActiveTab, txnsForActiveTab) | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
<!-- Transaction Details (all txns for the selected category, optionally filtered by GL/channel) -->
@if (showDetails) {
@if (rowsForActiveTab.length > 1) {
<div class="cctr-tabs">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="glFilter === null"
(click)="setGlFilter(null)"
>
All
</button>
@for (r of rowsForActiveTab; track r.code) {
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="glFilter === r.code"
(click)="setGlFilter(r.code)"
>
{{ filterLabel(r) }}
</button>
}
</div>
}
<div class="pg-card">
<div class="pos-section-label">
{{
activeTab === 'cash'
? 'Cash'
: activeTab === 'transfer'
? 'Transfer'
: activeTab === 'clearing'
? 'Clearing'
: 'Digital'
}}
— Transaction Details (Full Double Entry)
@if (glFilter !== null) {
· {{ glFilter }}
}
</div>
<table class="pg-table cctr-journal">
<thead>
<tr>
<th>Date</th>
<th>Voucher / Ref No</th>
<th>Dr / Cr</th>
<th>GL / Account</th>
<th class="num col-out">Debit</th>
<th class="num col-in">Credit</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (t of filteredTxns; track t.voucherNo) {
<tr class="cctr-journal-row cctr-journal-row--first">
<td>{{ asOnDate }}</td>
<td>
<span class="pg-chip">{{ display(t.voucherNo) }}</span>
</td>
<td><span class="cctr-drcr cctr-drcr--dr">Dr</span></td>
<td>
@if (t.debit > 0) {
<span class="pg-chip">{{ t.glCode }}</span> {{ glName(t.glCode) }} — {{ display(t.accountNo) }} ({{
t.accountName
}})
} @else {
<span class="cctr-contra">{{ contraLabel(t) }}</span>
}
</td>
<td class="num col-out">₹{{ legAmount(t) | number: '1.2-2' }}</td>
<td class="num"></td>
<td rowspan="2">
<span class="cctr-balanced-chip">✓ Balanced</span>
</td>
</tr>
<tr class="cctr-journal-row cctr-journal-row--second">
<td></td>
<td></td>
<td><span class="cctr-drcr cctr-drcr--cr">Cr</span></td>
<td>
@if (t.credit > 0) {
<span class="pg-chip">{{ t.glCode }}</span> {{ glName(t.glCode) }} — {{ display(t.accountNo) }} ({{
t.accountName
}})
} @else {
<span class="cctr-contra">{{ contraLabel(t) }}</span>
}
</td>
<td class="num"></td>
<td class="num col-in">₹{{ legAmount(t) | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="pg-empty">No transactions for this filter.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="4">Total ({{ filteredTxns.length }} vouchers)</td>
<td class="num">
₹{{ txnTotal(filteredTxns, 'debit') + txnTotal(filteredTxns, 'credit') | number: '1.2-2' }}
</td>
<td class="num">
₹{{ txnTotal(filteredTxns, 'debit') + txnTotal(filteredTxns, 'credit') | number: '1.2-2' }}
</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
}
</div>
@@ -0,0 +1,138 @@
/**
* 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/.
*/
.cctr-tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.cctr-total-row {
td {
background: #f1f5f9;
font-weight: 800;
border-top: 2px solid #cbd5e1;
border-bottom: none;
color: #0f172a;
}
}
.cctr-grand-summary {
display: flex;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
&__item {
flex: 1;
padding: 14px 20px;
display: flex;
flex-direction: column;
gap: 4px;
border-right: 1px solid #e2e8f0;
&:last-child {
border-right: none;
}
}
&__label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #64748b;
}
&__value {
font-size: 20px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #0f172a;
}
}
.cctr-subtext {
font-size: 11px;
color: #94a3b8;
margin-top: 2px;
}
.cctr-branch-row {
cursor: pointer;
&:hover td {
background: #eff6ff;
}
}
// Both journal lines of one voucher share a tint and sit flush against each other (no dotted
// divider between them) so the pair reads as a single entry; a hairline separates one voucher's
// pair from the next.
.cctr-journal-row {
&--first td {
border-bottom: none;
padding-bottom: 4px;
}
&--second td {
border-top: none;
padding-top: 4px;
border-bottom: 1px solid #e2e8f0;
}
&--first,
&--second {
&:hover td {
background: #f8fafc;
}
}
}
.cctr-drcr {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
padding: 1px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.03em;
&--dr {
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
&--cr {
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
}
}
.cctr-contra {
color: #64748b;
font-style: italic;
}
.cctr-balanced-chip {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
white-space: nowrap;
}
+88
View File
@@ -0,0 +1,88 @@
/**
* 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/.
*/
// Shared "Export to Excel" builder for Cashier reports (mirrors ../investment/investment-export.ts)
// — centralised so the ExcelJS boilerplate (title/meta/header rows, number formatting, safe sheet
// names, Blob download) is written once and every cashier report's export behaves the same way.
import * as ExcelJS from 'exceljs';
export interface ExportSheetSpec {
/** Worksheet tab name — sanitised for Excel's forbidden characters (\ / ? * [ ]) and length. */
sheetName: string;
/** Bold title row, merged across all columns. */
title: string;
/** Optional italic subtitle row (e.g. scope/date), merged across all columns. */
subtitle?: string;
headers: string[];
rows: (string | number | null)[][];
widths?: number[];
}
/** Excel worksheet names can't contain \ / ? * [ ] or exceed 31 chars. */
function safeSheetName(name: string): string {
return name.replace(/[\\/?*[\]]/g, '-').slice(0, 31);
}
function buildSheet(wb: ExcelJS.Workbook, spec: ExportSheetSpec): void {
const sheet = wb.addWorksheet(safeSheetName(spec.sheetName));
sheet.columns = (spec.widths || spec.headers.map(() => 18)).map((w) => ({ width: w }));
const title = sheet.addRow([spec.title]);
sheet.mergeCells(title.number, 1, title.number, spec.headers.length);
title.font = { bold: true, size: 14, color: { argb: 'FFFFFFFF' } };
title.alignment = { horizontal: 'center', vertical: 'middle' };
title.height = 24;
title.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0F4F3C' } };
if (spec.subtitle) {
const meta = sheet.addRow([spec.subtitle]);
sheet.mergeCells(meta.number, 1, meta.number, spec.headers.length);
meta.font = { italic: true, size: 10 };
}
sheet.addRow([]);
const head = sheet.addRow(spec.headers);
head.font = { bold: true };
head.alignment = { wrapText: true, horizontal: 'center' };
head.eachCell((c) => {
c.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } };
c.border = { bottom: { style: 'thin' } };
});
for (const r of spec.rows) {
const row = sheet.addRow(r);
r.forEach((v, idx) => {
if (typeof v === 'number') {
row.getCell(idx + 1).numFmt = '#,##0.00';
}
});
}
sheet.views = [{ state: 'frozen', ySplit: spec.subtitle ? 4 : 3 }];
}
/** Builds a (possibly multi-sheet) workbook and triggers a browser download. */
export async function exportWorkbook(sheets: ExportSheetSpec[], fileName: string): Promise<void> {
const wb = new ExcelJS.Workbook();
wb.creator = 'CBSNexora';
wb.created = new Date();
sheets.forEach((s) => buildSheet(wb, s));
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${fileName.replace(/[^\w]+/g, '-')}.xlsx`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
+11
View File
@@ -179,6 +179,17 @@ const routes: Routes = [
loadComponent: () =>
import('./my-deposit-requests/my-deposit-requests.component').then((m) => m.MyDepositRequestsComponent),
data: { title: 'My Deposit Requests', breadcrumb: 'My Deposit Requests' }
},
{
path: 'cash-clearing-transfer-summary',
loadComponent: () =>
import('./cash-clearing-transfer-summary/cash-clearing-transfer-summary.component').then(
(m) => m.CashClearingTransferSummaryComponent
),
data: {
title: 'Cash / Clearing / Transfer - Summary Report',
breadcrumb: 'Cash / Clearing / Transfer - Summary Report'
}
}
]
}
@@ -12,16 +12,16 @@
* have meaningful variety out of the box.
*/
export const SAMPLE_CKYC_FILE = `10|24401|IN4711|MH|30|31-08-2020|V1.1|||||
20|1|01|01|||||||||||01|||01|10078|MR|RAVI|KUMAR|SHARMA|||||||01|MR|RAJESH|KUMAR|SHARMA||MRS|SUNITA|DEVI|||M|02|IN|B-01|10-08-1985|||||||||01|02|||||02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||Y|02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||||||||||||||||9892011001|||||31-08-2020|MUMBAI MAIN|31-08-2020|01|ANJALI MEHTA|ASSISTANT MANAGER|HEAD OFFICE|4|BHARAT URBAN BANK|IN4711|1|0||0|4||||||
20|1|01|01|||||||||||01|||01|10078|MR|RAVI|KUMAR|SHARMA|||||||01|MR|RAJESH|KUMAR|SHARMA||MRS|SUNITA|DEVI|||M|02|IN|B-01|10-08-1985|||||||||01|02|||||02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||Y|02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||||||||||||||||9892011001|||||31-08-2020|MUMBAI MAIN|31-08-2020|01|ANJALI MEHTA|ASSISTANT MANAGER|HEAD OFFICE|4|CBSNEXORA BANK|IN4711|1|0||0|4||||||
30|1|C|AAQPS8560J||01|02|||||
70|1|10078_Sign.jpeg|09|01|01||||
70|1|10078_Photo.jpeg|02|01|01||||
70|1|10078_AP.jpg|04|01|01||||
20|2|01|01|||||||||||01|||01|10079|MRS|PRIYA||PATEL|||||||01|MR|HARISH|PATEL|||MRS|MEENA|PATEL|||F|01|IN|O-04|22-03-1990|||||||||01|02|||||02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||Y|02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||||||||||||||||9822055002|||||31-08-2020|PUNE FC ROAD|31-08-2020|01|VIKRAM SINGH|BRANCH MANAGER|HEAD OFFICE|4|BHARAT URBAN BANK|IN4711|1|0||0|3||||||
20|2|01|01|||||||||||01|||01|10079|MRS|PRIYA||PATEL|||||||01|MR|HARISH|PATEL|||MRS|MEENA|PATEL|||F|01|IN|O-04|22-03-1990|||||||||01|02|||||02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||Y|02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||||||||||||||||9822055002|||||31-08-2020|PUNE FC ROAD|31-08-2020|01|VIKRAM SINGH|BRANCH MANAGER|HEAD OFFICE|4|CBSNEXORA BANK|IN4711|1|0||0|3||||||
30|2|E|XXXXXXXX2210||01|02|||||
70|2|10079_Sign.jpeg|09|01|01||||
70|2|10079_Photo.jpeg|02|01|01||||
20|3|01|01|||||||||||01|||01|10080|MR|ARJUN||DESHMUKH|||||||01|MR|VIJAY|DESHMUKH|||MRS|REKHA|DESHMUKH|||M|01|IN|B-01|15-01-1978|||||||||01|02|||||02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||Y|02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||||||||||||||||9823033003|||||30-08-2020|NAGPUR DHARAMPETH|30-08-2020|01|RAHUL JOSHI|ASSISTANT MANAGER|HEAD OFFICE|4|BHARAT URBAN BANK|IN4711|1|0||0|3||||||
20|3|01|01|||||||||||01|||01|10080|MR|ARJUN||DESHMUKH|||||||01|MR|VIJAY|DESHMUKH|||MRS|REKHA|DESHMUKH|||M|01|IN|B-01|15-01-1978|||||||||01|02|||||02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||Y|02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||||||||||||||||9823033003|||||30-08-2020|NAGPUR DHARAMPETH|30-08-2020|01|RAHUL JOSHI|ASSISTANT MANAGER|HEAD OFFICE|4|CBSNEXORA BANK|IN4711|1|0||0|3||||||
30|3|C|AGTPD1144K||01|02|||||
70|3|10080_Sign.jpeg|09|01|01||||
70|3|10080_AP.jpg|04|01|01||||
@@ -533,6 +533,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
screeningLogId: number | null = null;
/** Snapshot of the name that was last verified — used to invalidate verification when name changes */
private verifiedName: string = '';
/** True while prefillForEdit() is patching historical monthly/annual values — see linkMonthlyAnnual(). */
private suppressMonthlyAnnualLink = false;
/**
* @param {FormBuilder} formBuilder Form Builder
@@ -709,6 +711,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
const data = this.clientData;
this.loadExistingSeniorPwdDocuments();
// Loading an existing client's independently-saved monthly/annual values must not run
// through the auto-calc in linkMonthlyAnnual() — patching monthlyIncome then annualIncome
// (or vice versa) would otherwise have each overwrite the other via its valueChanges,
// instead of keeping the exact historical values as saved.
this.suppressMonthlyAnnualLink = true;
// Patch all base controls that exist on the form
this.createClientForm.patchValue({
officeId: data.officeId,
@@ -743,6 +751,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
? new Date(data.timeline.submittedOnDate)
: this.settingsService.businessDate
});
this.suppressMonthlyAnnualLink = false;
// Patch name fields (already created by buildDependencies via legalFormId subscription)
const legalFormId = data.legalForm?.id || LegalFormId.PERSON;
@@ -954,6 +963,43 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
Validators.required
]
});
this.linkMonthlyAnnual('monthlyIncome', 'annualIncome');
this.linkMonthlyAnnual('monthlyTurnover', 'annualTurnover');
}
/**
* Keeps a Monthly/Annual pair of amount fields in sync at a 12x ratio — typing into either
* one auto-fills the other. Uses { emitEvent: false } on the write side so the two
* subscriptions don't trigger each other back and forth.
*/
/** Rounds to at most 2 decimals without padding whole numbers with a trailing ".00". */
private round2(n: number): string {
return (Math.round(n * 100) / 100).toString();
}
private linkMonthlyAnnual(monthlyControlName: string, annualControlName: string): void {
const monthly = this.createClientForm.get(monthlyControlName);
const annual = this.createClientForm.get(annualControlName);
if (!monthly || !annual) {
return;
}
monthly.valueChanges.subscribe((value) => {
if (this.suppressMonthlyAnnualLink) {
return;
}
const num = parseFloat(value);
annual.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 12) : '', { emitEvent: false });
});
annual.valueChanges.subscribe((value) => {
if (this.suppressMonthlyAnnualLink) {
return;
}
const num = parseFloat(value);
monthly.setValue(!isNaN(num) && num >= 0 ? this.round2(num / 12) : '', { emitEvent: false });
});
}
/**
@@ -2190,26 +2236,26 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
}
// Match monthly income ranges
if (rule.minMonthlyIncome != null && monthlyIncome < rule.minMonthlyIncome) {
if (rule.minMonthlyIncome != null && rule.minMonthlyIncome > 0 && monthlyIncome < rule.minMonthlyIncome) {
continue;
}
if (rule.maxMonthlyIncome != null && monthlyIncome > rule.maxMonthlyIncome) {
if (rule.maxMonthlyIncome != null && rule.maxMonthlyIncome > 0 && monthlyIncome > rule.maxMonthlyIncome) {
continue;
}
// Match annual income ranges (evaluated against totalAnnualIncome)
if (rule.minAnnualIncome != null && totalAnnualIncome < rule.minAnnualIncome) {
if (rule.minAnnualIncome != null && rule.minAnnualIncome > 0 && totalAnnualIncome < rule.minAnnualIncome) {
continue;
}
if (rule.maxAnnualIncome != null && totalAnnualIncome > rule.maxAnnualIncome) {
if (rule.maxAnnualIncome != null && rule.maxAnnualIncome > 0 && totalAnnualIncome > rule.maxAnnualIncome) {
continue;
}
// Match turnover ranges
if (rule.minAnnualTurnover != null && annualTurnover < rule.minAnnualTurnover) {
if (rule.minAnnualTurnover != null && rule.minAnnualTurnover > 0 && annualTurnover < rule.minAnnualTurnover) {
continue;
}
if (rule.maxAnnualTurnover != null && annualTurnover > rule.maxAnnualTurnover) {
if (rule.maxAnnualTurnover != null && rule.maxAnnualTurnover > 0 && annualTurnover > rule.maxAnnualTurnover) {
continue;
}
@@ -440,6 +440,18 @@ export class AuthenticationService {
return roleNames.some((n) => n === 'cashier');
}
/**
* Returns true when the logged-in user belongs to the Head Office.
* Fineract seeds Head Office as officeId 1 with hierarchy "."; office name is
* checked too since some deployments rename/reseed the root office.
*/
isHeadOfficeUser(): boolean {
const creds = this.getCredentials();
if (!creds) return false;
if (creds.officeId === 1) return true;
return (creds.officeName || '').toLowerCase().includes('head office');
}
/**
* Sets the user credentials.
*
+98
View File
@@ -0,0 +1,98 @@
/**
* 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 { AuthenticationService } from './authentication/authentication.service';
import { GL_BRANCHES, GlBranch } from 'app/gl-reports/gl-reports-data';
/**
* Resolves what branch data the logged-in user is allowed to see.
*
* Head Office sees every branch and can drill into any one of them; a branch login is
* locked to its own book. Previously this logic was copy-pasted into the Cash/Clearing
* Summary and the NPA report with slightly different branch code lists; this service is
* the single place it lives so new reports cannot drift.
*
* The logged-in office is matched to a branch by name, since session credentials carry
* only officeId and officeName. Office names are seeded to match the GL branch names
* exactly (see Liquibase 0442).
*/
@Injectable({ providedIn: 'root' })
export class BranchScopeService {
private auth = inject(AuthenticationService);
/** Sentinel used when a branch login cannot be matched to a known branch. */
static readonly UNRESOLVED = '__unresolved__';
get isHeadOffice(): boolean {
return this.auth.isHeadOfficeUser();
}
/** The office name on the session, for labelling the scope in report headers. */
get officeName(): string {
return this.auth.getCredentials()?.officeName || 'Branch Office';
}
/**
* Branch code the user is confined to, or null for Head Office.
*
* Deliberately returns the UNRESOLVED sentinel rather than falling back to the first
* branch when the office cannot be matched: showing an unrecognised branch someone
* else's figures would be worse than showing them nothing.
*/
get ownBranchCode(): string | null {
if (this.isHeadOffice) {
return null;
}
const name = this.officeName.toLowerCase().trim();
const match = GL_BRANCHES.find((b) => b.code !== 'HO' && name === b.name.toLowerCase());
// Fall back to a substring match for offices named e.g. "Andheri (E) Branch".
const loose = match ?? GL_BRANCHES.find((b) => b.code !== 'HO' && name.includes(b.name.toLowerCase()));
return loose?.code ?? BranchScopeService.UNRESOLVED;
}
/** True when the user may choose which branch to view. */
get canSelectBranch(): boolean {
return this.isHeadOffice;
}
/** Branches offered in a selector — everything for Head Office, nothing for a branch login. */
get selectableBranches(): GlBranch[] {
return this.isHeadOffice ? GL_BRANCHES : [];
}
/** Branch code a report should start on. '' means all branches consolidated. */
get defaultBranchCode(): string {
return this.isHeadOffice ? '' : (this.ownBranchCode ?? '');
}
/**
* Forces a requested branch code into what the user is actually allowed to see.
* Call this wherever a branch code reaches a data query, so a stale or hand-edited
* selection cannot widen the user's scope.
*/
enforce(requested: string): string {
return this.isHeadOffice ? requested : (this.ownBranchCode ?? BranchScopeService.UNRESOLVED);
}
/** True when a branch login's office matched no known branch, so it sees no data. */
get isUnresolved(): boolean {
return this.ownBranchCode === BranchScopeService.UNRESOLVED;
}
/** Human label for the current scope, for report headers. */
scopeLabel(selectedCode: string): string {
if (!this.isHeadOffice) {
return this.officeName;
}
if (!selectedCode) {
return 'All Branches (Consolidated)';
}
return GL_BRANCHES.find((b) => b.code === selectedCode)?.name ?? selectedCode;
}
}
+368 -67
View File
@@ -350,6 +350,10 @@
<fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="loanOriginationMenu" *mifosxHasPermission="'MENU_ADVANCES'">
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="advancesMenu" *mifosxHasPermission="'MENU_ADVANCES'">
<fa-icon icon="hand-holding-usd" class="ni-ico"></fa-icon>Advances
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
@@ -362,6 +366,10 @@
<fa-icon icon="university" class="ni-ico"></fa-icon>RBI
<fa-icon [icon]="rbiMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" (click)="investmentMenuOpen = !investmentMenuOpen; rbiMenuOpen = false; glMenuOpen = false" *mifosxHasPermission="'MENU_RBI'">
<fa-icon icon="chart-line" class="ni-ico"></fa-icon>Investment
<fa-icon [icon]="investmentMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="digitalMenu" *mifosxHasPermission="'MENU_DIGITAL'">
<fa-icon icon="mobile-alt" class="ni-ico"></fa-icon>Digital
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
@@ -438,6 +446,10 @@
<fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="loanOriginationMenu" *mifosxHasPermission="'MENU_ADVANCES'">
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a
class="ni"
tabindex="0"
@@ -492,6 +504,15 @@
<fa-icon icon="university" class="ni-ico"></fa-icon>RBI
<fa-icon [icon]="rbiMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
<a
class="ni"
tabindex="0"
(click)="investmentMenuOpen = !investmentMenuOpen; rbiMenuOpen = false; glMenuOpen = false"
*mifosxHasPermission="'MENU_RBI'"
>
<fa-icon icon="chart-line" class="ni-ico"></fa-icon>Investment
<fa-icon [icon]="investmentMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="reportsMenu" *mifosxHasPermission="'MENU_REPORTS'">
<fa-icon icon="chart-bar" class="ni-ico"></fa-icon>Reports
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
@@ -566,6 +587,13 @@
<button mat-menu-item [routerLink]="['/reports', 'Accounting']" tabindex="0">
<i class="fa fa-calculator menu-item-icon"></i> Accounting
</button>
<mat-divider></mat-divider>
<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>
</mat-menu>
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
@@ -653,6 +681,7 @@
<a
class="gl-link"
[class.hidden]="!matchesSubLedgerSearch('Cash / Clearing / Transfer')"
routerLink="/cashier/cash-clearing-transfer-summary"
(click)="subLedgerOpen = false"
><fa-icon icon="money-bill" class="gl-icon"></fa-icon> Cash / Clearing / Transfer</a
>
@@ -701,8 +730,11 @@
</div>
<div class="gl-group">
<div class="gl-group-title">Deposits</div>
<a class="gl-link" [class.hidden]="!matchesSubLedgerSearch('FD / RD Entry')" (click)="subLedgerOpen = false"
><fa-icon icon="piggy-bank" class="gl-icon"></fa-icon> FD / RD Entry</a
<a
class="gl-link"
[class.hidden]="!matchesSubLedgerSearch('FD / RD Entry')"
(click)="subLedgerOpen = false; openFdRdEntryDialog()"
><fa-icon icon="piggy-bank" class="gl-icon" style="color: #0f766e"></fa-icon> FD / RD Entry</a
>
<a class="gl-link" [class.hidden]="!matchesSubLedgerSearch('DDS Entry')" (click)="subLedgerOpen = false"
><fa-icon icon="coins" class="gl-icon"></fa-icon> DDS Entry</a
@@ -808,50 +840,72 @@
<div class="gl-columns">
<div class="gl-group">
<div class="gl-group-title">Financial Statements</div>
<a class="gl-link" [class.hidden]="!matchesGlSearch('Trial Balance')" (click)="glMenuOpen = false"
><fa-icon icon="balance-scale" class="gl-icon"></fa-icon> Trial Balance</a
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Trial Balance')"
routerLink="/gl/reports/trial-balance"
(click)="glMenuOpen = false"
><fa-icon icon="balance-scale" class="gl-icon" style="color: #b45309"></fa-icon> Trial Balance</a
>
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Profit and Loss')"
(click)="glMenuOpen = false; openAccountingReportsDialog('PNL')"
routerLink="/gl/reports/profit-and-loss"
(click)="glMenuOpen = false"
><fa-icon icon="chart-line" class="gl-icon" style="color: #b45309"></fa-icon> Profit and Loss</a
>
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Consolidate Balance Sheet')"
(click)="glMenuOpen = false; openAccountingReportsDialog('BALANCE-SHEET')"
><fa-icon icon="file-alt" class="gl-icon" style="color: #b45309"></fa-icon> Consolidate Balance Sheet</a
routerLink="/gl/reports/balance-sheet"
(click)="glMenuOpen = false"
><fa-icon icon="file-alt" class="gl-icon" style="color: #b45309"></fa-icon> Consolidated Balance Sheet</a
>
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Comparative Balance Sheet')"
(click)="glMenuOpen = false; openAccountingReportsDialog('COMPARATIVE-BS')"
routerLink="/gl/reports/comparative-balance-sheet"
(click)="glMenuOpen = false"
><fa-icon icon="balance-scale" class="gl-icon" style="color: #b45309"></fa-icon> Comparative Balance
Sheet</a
>
</div>
<div class="gl-group">
<div class="gl-group-title">GL Reports</div>
<a class="gl-link" [class.hidden]="!matchesGlSearch('GL Average Balance')" (click)="glMenuOpen = false"
><fa-icon icon="calculator" class="gl-icon"></fa-icon> GL Average Balance</a
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('GL Average Balance')"
routerLink="/gl/reports/gl-average-balance"
(click)="glMenuOpen = false"
><fa-icon icon="calculator" class="gl-icon" style="color: #b45309"></fa-icon> GL Average Balance</a
>
<a class="gl-link" [class.hidden]="!matchesGlSearch('GL Mapping for Report')" (click)="glMenuOpen = false"
><fa-icon icon="project-diagram" class="gl-icon"></fa-icon> GL Mapping for Report</a
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('GL Mapping for Report')"
routerLink="/gl/reports/gl-mapping"
(click)="glMenuOpen = false"
><fa-icon icon="project-diagram" class="gl-icon" style="color: #b45309"></fa-icon> GL Mapping for
Report</a
>
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Account wise Balance List')"
routerLink="/gl/reports/account-wise-balance"
(click)="glMenuOpen = false"
><fa-icon icon="list" class="gl-icon"></fa-icon> Account wise Balance List</a
><fa-icon icon="list" class="gl-icon" style="color: #b45309"></fa-icon> Account wise Balance List</a
>
<a class="gl-link" [class.hidden]="!matchesGlSearch('Distribution of Balance')" (click)="glMenuOpen = false"
><fa-icon icon="chart-pie" class="gl-icon"></fa-icon> Distribution of Balance</a
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Distribution of Balance')"
routerLink="/gl/reports/distribution-of-balance"
(click)="glMenuOpen = false"
><fa-icon icon="chart-pie" class="gl-icon" style="color: #b45309"></fa-icon> Distribution of Balance</a
>
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('GL Wise DR CR')"
(click)="glMenuOpen = false; openAccountingReportsDialog('GL-DR-CR')"
routerLink="/gl/reports/gl-wise-dr-cr"
(click)="glMenuOpen = false"
><fa-icon icon="project-diagram" class="gl-icon" style="color: #b45309"></fa-icon> GL Wise DR / CR
Report</a
>
@@ -861,28 +915,47 @@
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Client wise Balance Report')"
routerLink="/gl/reports/client-wise-balance"
(click)="glMenuOpen = false"
><fa-icon icon="users" class="gl-icon"></fa-icon> Client wise Balance Report</a
><fa-icon icon="users" class="gl-icon" style="color: #b45309"></fa-icon> Client wise Balance Report</a
>
<a class="gl-link" [class.hidden]="!matchesGlSearch('Contingent Expense')" (click)="glMenuOpen = false"
><fa-icon icon="exclamation-circle" class="gl-icon"></fa-icon> Contingent Expense</a
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Contingent Liabilities')"
routerLink="/gl/reports/contingent-liabilities"
(click)="glMenuOpen = false"
><fa-icon icon="exclamation-circle" class="gl-icon" style="color: #b45309"></fa-icon> Contingent
Liabilities</a
>
<a class="gl-link" [class.hidden]="!matchesGlSearch('Banker Head Report')" (click)="glMenuOpen = false"
><fa-icon icon="university" class="gl-icon"></fa-icon> Banker Head Report</a
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Banker Head Report')"
routerLink="/gl/reports/banker-head"
(click)="glMenuOpen = false"
><fa-icon icon="university" class="gl-icon" style="color: #b45309"></fa-icon> Banker Head Report</a
>
</div>
<div class="gl-group">
<div class="gl-group-title">Registers</div>
<a class="gl-link" [class.hidden]="!matchesGlSearch('Sundry Register')" (click)="glMenuOpen = false"
><fa-icon icon="clipboard-list" class="gl-icon"></fa-icon> Sundry Register</a
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Sundry Register')"
routerLink="/gl/reports/sundry-register"
(click)="glMenuOpen = false"
><fa-icon icon="clipboard-list" class="gl-icon" style="color: #b45309"></fa-icon> Sundry Register</a
>
<a class="gl-link" [class.hidden]="!matchesGlSearch('Voucher Register')" (click)="glMenuOpen = false"
><fa-icon icon="receipt" class="gl-icon"></fa-icon> Voucher Register</a
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Voucher Register')"
routerLink="/gl/reports/voucher-register"
(click)="glMenuOpen = false"
><fa-icon icon="receipt" class="gl-icon" style="color: #b45309"></fa-icon> Voucher Register</a
>
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Misc Expense Ledger')"
(click)="glMenuOpen = false; openAccountingReportsDialog('MISC-EXP')"
routerLink="/gl/reports/misc-expense-ledger"
(click)="glMenuOpen = false"
><fa-icon icon="receipt" class="gl-icon" style="color: #b45309"></fa-icon> Misc Expense Ledger</a
>
</div>
@@ -1155,7 +1228,7 @@
<fa-icon icon="user-slash" class="menu-item-icon" style="color: #c2410c"></fa-icon> Non-Customer Entry
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="lock" class="menu-item-icon"></fa-icon> Locker</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item routerLink="/investment/bonds" tabindex="0">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> SLR and Non SLR Investment Report
</button>
<button mat-menu-item tabindex="0" (click)="openCicReportingDialog()">
@@ -1171,13 +1244,13 @@
<!-- Investment -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Investment</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item routerLink="/investment/dashboard" tabindex="0">
<fa-icon icon="calendar-alt" class="menu-item-icon"></fa-icon> Due Date Diary for All Investment
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item routerLink="/investment/reports" tabindex="0">
<fa-icon icon="dollar-sign" class="menu-item-icon"></fa-icon> Total Int Received &amp; Receivable Report
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item routerLink="/investment/reports" tabindex="0">
<fa-icon icon="piggy-bank" class="menu-item-icon"></fa-icon> Bucket Wise Investment
</button>
<button mat-menu-item routerLink="/deaf" tabindex="0">
@@ -1208,39 +1281,96 @@
<!-- NPA Reports -->
<div class="deposit-menu-group">
<div class="deposit-group-title">NPA Reports</div>
<button mat-menu-item tabindex="0">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> NPA Interest
<button mat-menu-item tabindex="0" routerLink="/npa/reports/interest">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> NPA Report
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/npa/reports/status">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> NPA Status Report
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/npa/reports/recovery">
<fa-icon icon="undo" class="menu-item-icon"></fa-icon> NPA Recovery Report
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/npa/reports/summary">
<fa-icon icon="clipboard-list" class="menu-item-icon"></fa-icon> NPA Summary Report
</button>
</div>
<!-- NPA Analysis -->
<div class="deposit-menu-group">
<div class="deposit-group-title">NPA Analysis</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/npa/reports/account-detail">
<fa-icon icon="list-alt" class="menu-item-icon"></fa-icon> NPA Account Detail Report
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/npa/reports/movement">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> NPA Movement Report
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/npa/reports/provision">
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> NPA with Provision and Required Provision
</button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/undebited-interest">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Undebited Interest Recovery Report
</button>
</div>
<!-- NPA Classification -->
<div class="deposit-menu-group">
<div class="deposit-group-title">NPA Classification</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/npa/reports/probable">
<fa-icon icon="exclamation-circle" class="menu-item-icon"></fa-icon> Probable NPA
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="flag" class="menu-item-icon"></fa-icon> SMA Report</button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/sma">
<fa-icon icon="flag" class="menu-item-icon"></fa-icon> SMA Report
</button>
</div>
<!-- Recovery Management -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Recovery Management</div>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/recovery-dashboard">
<fa-icon icon="tachometer-alt" class="menu-item-icon"></fa-icon> Recovery Management Dashboard
</button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/daywise-overdue">
<fa-icon icon="calendar-alt" class="menu-item-icon"></fa-icon> Day-wise Overdue Report
</button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/daywise-overdrawn">
<fa-icon icon="credit-card" class="menu-item-icon"></fa-icon> Day-wise Overdrawn Report
</button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/agri-gold-value">
<fa-icon icon="crop" class="menu-item-icon"></fa-icon> Agriculture / Gold Market Value Report
</button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/exception-check">
<fa-icon icon="balance-scale" class="menu-item-icon"></fa-icon> Exception Check — Security Value vs Loan
</button>
</div>
</div>
</mat-menu>
<mat-menu #loanOriginationMenu="matMenu" class="npa-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid npa-grid" (click)="$event.stopPropagation()">
<!-- Loan Origination -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Loan Origination</div>
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline">
<fa-icon icon="stream" class="menu-item-icon"></fa-icon> Application Pipeline
</button>
</div>
<!-- Reports -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Reports</div>
<button
mat-menu-item
tabindex="0"
routerLink="/loan-origination/pipeline"
[queryParams]="{ view: 'channel' }"
>
<fa-icon icon="chart-pie" class="menu-item-icon"></fa-icon> Channel-wise Report
</button>
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline" [queryParams]="{ view: 'bc' }">
<fa-icon icon="mobile-alt" class="menu-item-icon"></fa-icon> BC-wise Report
</button>
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline" [queryParams]="{ view: 'share' }">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Share Member Report
</button>
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline" [queryParams]="{ view: 'target' }">
<fa-icon icon="chart-column" class="menu-item-icon"></fa-icon> Target vs Actual
</button>
</div>
</div>
</mat-menu>
@@ -1273,27 +1403,27 @@
<!-- Core / Books -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Core &amp; Books</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/reconciliation/voucher">
<fa-icon icon="receipt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Voucher Reconciliation
</button>
<button mat-menu-item tabindex="0">
<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
</button>
<button mat-menu-item tabindex="0">
<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
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/reconciliation/gl-reconciliation">
<fa-icon icon="book-open" class="menu-item-icon" style="color: #0d9488"></fa-icon>
GL Reconciliation
</button>
<button mat-menu-item tabindex="0">
<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
</button>
<button mat-menu-item tabindex="0">
<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
</button>
@@ -1302,19 +1432,19 @@
<!-- Bank & Clearing -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Bank &amp; Clearing</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/reconciliation/bank-reco">
<fa-icon icon="university" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Bank Reconciliation
</button>
<button mat-menu-item tabindex="0">
<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
</button>
<button mat-menu-item tabindex="0">
<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
</button>
<button mat-menu-item tabindex="0">
<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
</button>
@@ -1323,15 +1453,15 @@
<!-- Digital / Payments -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Digital &amp; Payments</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/reconciliation/udir">
<fa-icon icon="mobile-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
UDIR (IMPS / UPI / ATM)
</button>
<button mat-menu-item tabindex="0">
<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
</button>
<button mat-menu-item tabindex="0">
<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
</button>
@@ -1344,7 +1474,7 @@
<fa-icon icon="money-check-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
DD / Pay Order Reconciliation
</button>
<button mat-menu-item tabindex="0">
<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
</button>
@@ -1353,11 +1483,11 @@
<!-- Compliance & Financials -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Compliance &amp; Financials</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/reconciliation/invoice-payment">
<fa-icon icon="file-invoice" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Invoice Payment
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/reconciliation/tax-reco">
<fa-icon icon="percentage" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Tax Reconciliation
</button>
@@ -1469,6 +1599,9 @@
<button mat-menu-item tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 98
</button>
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
<fa-icon icon="file-invoice" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 97/98 Txn Report
</button>
<button mat-menu-item routerLink="/letter-mgmt/form-15gh" tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 121
</button>
@@ -1673,28 +1806,41 @@
>
<fa-icon icon="shield-alt" class="gl-icon"></fa-icon> AML
</a>
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('Risk Review Report')"
routerLink="/reports/risk-review"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="user-shield" class="gl-icon"></fa-icon> Risk Review Report
</a>
</div>
<!-- OSS Reports -->
<div class="gl-group">
<div class="gl-group-title">OSS Reports</div>
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 1 XBRL/CIMS')" (click)="rbiMenuOpen = false">
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 1 XBRL/CIMS
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('OSS 1 XBRL/CIMS')"
routerLink="/rbi/oss1"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 1 XBRL/CIMS
</a>
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('OSS 2 XBRL')"
(click)="rbiMenuOpen = false; openCimsXbrlDialog('OSS-2')"
routerLink="/rbi/oss2"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 2 XBRL
</a>
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 3 XBRL/CIMS')" (click)="rbiMenuOpen = false">
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 3 XBRL/CIMS
</a>
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 4/OSS 5/OSS 6')" (click)="rbiMenuOpen = false">
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 4 / OSS 5 / OSS 6
</a>
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 7')" (click)="rbiMenuOpen = false">
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 7
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('OSS 3 XBRL/CIMS')"
routerLink="/rbi/oss3"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 3 XBRL/CIMS
</a>
</div>
<!-- CIMS / XBRL Returns -->
@@ -1825,6 +1971,161 @@
</div>
}
<!-- Investment (Treasury) Mega Menu -->
@if (investmentMenuOpen) {
<div class="gl-backdrop" (click)="investmentMenuOpen = false"></div>
<div class="gl-mega-menu rbi-mega-menu">
<div class="gl-mega-menu-header">
<fa-icon icon="chart-line" class="gl-mega-menu-header-icon"></fa-icon>
<span>Investment</span>
</div>
<input
type="text"
class="gl-search"
placeholder="Search Investment..."
(input)="investmentSearch = $any($event.target).value"
/>
<div class="rbi-columns">
<!-- Dashboard -->
<div class="gl-group">
<div class="gl-group-title">Dashboard</div>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Investment Dashboard')"
routerLink="/investment/dashboard"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="chart-pie" class="gl-icon"></fa-icon> Portfolio Dashboard
</a>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Due Date Diary for All Investment')"
routerLink="/investment/dashboard"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="calendar-check" class="gl-icon"></fa-icon> Due Date Diary for All Investment
</a>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Bucket Wise Investment')"
routerLink="/investment/reports"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="layer-group" class="gl-icon"></fa-icon> Bucket Wise Investment
</a>
</div>
<!-- Entry -->
<div class="gl-group">
<div class="gl-group-title">Entry</div>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('FD Investment')"
routerLink="/investment/fd"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="piggy-bank" class="gl-icon"></fa-icon> FD Investment
</a>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Govt Security / Non-SLR / Bond / Mutual Fund')"
routerLink="/investment/bonds"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="university" class="gl-icon"></fa-icon> Govt Security / Non-SLR / Bond / Mutual Fund
</a>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('SLR and Non SLR Investment Report')"
routerLink="/investment/bonds"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="balance-scale" class="gl-icon"></fa-icon> SLR and Non SLR Investment Report
</a>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Share Entry')"
routerLink="/investment/share"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="chart-line" class="gl-icon"></fa-icon> Share Entry
</a>
</div>
<!-- Masters -->
<div class="gl-group">
<div class="gl-group-title">Masters</div>
<a
class="gl-link"
[class.hidden]="
!matchesInvestmentSearch(
'Bank Party Script Broker SubType Control Parameter Daily Script LIEN Reason Mutual Fund Product SGL Bank Script Market Detail Masters'
)
"
routerLink="/investment/masters"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="cogs" class="gl-icon"></fa-icon> Bank/Party, Script, Broker &amp; Other Masters
</a>
</div>
<!-- Interest -->
<div class="gl-group">
<div class="gl-group-title">Interest</div>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('FD Interest Calculation')"
routerLink="/investment/interest"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="calculator" class="gl-icon"></fa-icon> FD Interest Calculation
</a>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Govt Bond Interest Calculation')"
routerLink="/investment/interest"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="calculator" class="gl-icon"></fa-icon> Govt./Bond Int. Calculation
</a>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Government Bond Int Register FD Interest Register FD Register')"
routerLink="/investment/interest"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="list-alt" class="gl-icon"></fa-icon> Bond Int / FD Int / FD Registers
</a>
</div>
<!-- Reports -->
<div class="gl-group">
<div class="gl-group-title">Reports</div>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Balance Ledger Maturity Investment Reports')"
routerLink="/investment/reports"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="file-invoice" class="gl-icon"></fa-icon> Balance / Ledger / Maturity Reports
</a>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Total Int Received & Receivable Report')"
routerLink="/investment/reports"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="hand-holding-usd" class="gl-icon"></fa-icon> Total Int Received &amp; Receivable Report
</a>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Investment Portfolio')"
routerLink="/investment/reports"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="briefcase" class="gl-icon"></fa-icon> Investment Portfolio
</a>
</div>
</div>
</div>
}
<!-- Progress Bar -->
@if (progressBarMode !== 'none') {
<div>
+41
View File
@@ -677,6 +677,26 @@ $gray-700: #334155;
opacity: 0.7;
}
// Marks a `mat-menu-item` as actually wired to a working screen/dialog (vs a still-unwired
// placeholder) — same "green dot" convention as .gl-link--live, positioned absolutely since
// mat-menu-item's internal layout isn't a plain flex row we control directly.
[mat-menu-item].menu-item-live {
position: relative;
&::after {
content: '';
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
width: 6px;
height: 6px;
border-radius: 50%;
background: #16a34a;
box-shadow: 0 0 0 2px rgb(22 163 74 / 15%);
}
}
// ===== Nav Dropdown Menus =====
::ng-deep .nav-dropdown {
border-radius: 8px !important;
@@ -729,6 +749,13 @@ $gray-700: #334155;
}
}
/* 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;
}
/* Reconciliation Mega Panel — 5 columns, teal accent to match the icon palette */
.reconciliation-grid {
grid-template-columns: repeat(5, minmax(190px, 1fr));
@@ -856,6 +883,20 @@ $gray-700: #334155;
}
}
// 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;
margin-left: auto;
flex-shrink: 0;
border-radius: 50%;
background: #16a34a;
box-shadow: 0 0 0 2px rgb(22 163 74 / 15%);
}
/* RBI Mega Menu */
.rbi-mega-menu {
width: 1100px;
+33 -2
View File
@@ -98,6 +98,10 @@ export class ShellComponent implements OnInit, OnDestroy {
rbiMenuOpen = false;
rbiSearch = '';
/** Investment (Treasury) mega menu state */
investmentMenuOpen = false;
investmentSearch = '';
/* Live KPI ticker — markup is static inside the template; the marquee
animation, LIVE badge, and pause-on-hover handle the live feel. */
@@ -116,6 +120,11 @@ export class ShellComponent implements OnInit, OnDestroy {
return label.toLowerCase().includes(this.rbiSearch.toLowerCase());
}
matchesInvestmentSearch(label: string): boolean {
if (!this.investmentSearch) return true;
return label.toLowerCase().includes(this.investmentSearch.toLowerCase());
}
/** Route for a facility entry in the Account Facilities hover dropdown — only "Cheque Book" has a
* dedicated action page today; every other facility falls back to the existing overview page. */
facilityRoute(label: string): string[] {
@@ -242,7 +251,7 @@ export class ShellComponent implements OnInit, OnDestroy {
* GL Wise DR/CR / Misc Expense Ledger). Initial report selected via menu link.
*/
async openAccountingReportsDialog(
initialReport: 'PNL' | 'BALANCE-SHEET' | 'COMPARATIVE-BS' | 'GL-DR-CR' | 'MISC-EXP' = 'PNL'
initialReport: 'PNL' | 'BALANCE-SHEET' | 'COMPARATIVE-BS' | 'CONTINGENT-LIAB' | 'GL-DR-CR' | 'MISC-EXP' = 'PNL'
): Promise<void> {
const { AccountingReportsDialogComponent } =
await import('../../home/dashboard/accounting-reports-dialog/accounting-reports-dialog.component');
@@ -285,7 +294,9 @@ export class ShellComponent implements OnInit, OnDestroy {
* is selected based on the menu link the user clicked; user can switch between
* forms via the form-pill selector inside the dialog.
*/
async openCimsXbrlDialog(initialForm: 'OSS-2' | 'PSA' | 'RAL' | 'RCP-UCB' = 'OSS-2'): Promise<void> {
async openCimsXbrlDialog(
initialForm: 'OSS-2' | 'PSA' | 'RAL' | 'RCP-UCB' | 'OSS-4' | 'OSS-5' | 'OSS-6' | 'OSS-7' = 'OSS-2'
): Promise<void> {
const { CimsXbrlDialogComponent } =
await import('../../home/dashboard/cims-xbrl-dialog/cims-xbrl-dialog.component');
this.dialog.open(CimsXbrlDialogComponent, {
@@ -301,6 +312,26 @@ export class ShellComponent implements OnInit, OnDestroy {
});
}
/** Opens the FD / RD Entry dialog under Sub Ledger → Deposits. */
async openFdRdEntryDialog(): Promise<void> {
const { FdRdEntryDialogComponent } =
await import('../../home/dashboard/fd-rd-entry-dialog/fd-rd-entry-dialog.component');
this.dialog.open(FdRdEntryDialogComponent, {
width: '100vw',
height: '100vh',
maxWidth: '100vw',
maxHeight: '100vh',
panelClass: [
'crr-slr-dialog-panel',
'fdrd-fullscreen-panel'
],
autoFocus: false,
disableClose: false,
hasBackdrop: true,
backdropClass: 'fdrd-dlg-backdrop'
});
}
/**
* Opens the Asset Liability Management dialog (Form ALM-1).
* Covers maturity ladder, monthly snapshot, and structural liquidity returns.
@@ -266,7 +266,7 @@
<a
class="subledger-link"
[class.hidden]="!matchesSearch('Cash / Clearing / Transfer')"
(click)="subLedgerOpen = false"
(click)="navigateMenu('/cashier/cash-clearing-transfer-summary'); subLedgerOpen = false"
>
<fa-icon icon="money-bill" class="subledger-icon"></fa-icon> Cash / Clearing / Transfer
</a>
@@ -403,17 +403,26 @@
<div class="subledger-columns">
<div class="subledger-group">
<div class="subledger-group-title">Financial Statements</div>
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Trial Balance')" (click)="glMenuOpen = false"
<a
class="subledger-link"
[class.hidden]="!matchesGlSearch('Trial Balance')"
routerLink="/gl/reports/trial-balance"
(click)="glMenuOpen = false"
><fa-icon icon="balance-scale" class="subledger-icon"></fa-icon> Trial Balance</a
>
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Profit and Loss')" (click)="glMenuOpen = false"
<a
class="subledger-link"
[class.hidden]="!matchesGlSearch('Profit and Loss')"
routerLink="/gl/reports/profit-and-loss"
(click)="glMenuOpen = false"
><fa-icon icon="chart-line" class="subledger-icon"></fa-icon> Profit and Loss</a
>
<a
class="subledger-link"
[class.hidden]="!matchesGlSearch('Consolidate Balance Sheet')"
[class.hidden]="!matchesGlSearch('Consolidated Balance Sheet')"
routerLink="/gl/reports/balance-sheet"
(click)="glMenuOpen = false"
><fa-icon icon="file-alt" class="subledger-icon"></fa-icon> Consolidate Balance Sheet</a
><fa-icon icon="file-alt" class="subledger-icon"></fa-icon> Consolidated Balance Sheet</a
>
</div>
<div class="subledger-group">
@@ -448,8 +457,12 @@
(click)="glMenuOpen = false"
><fa-icon icon="users" class="subledger-icon"></fa-icon> Client wise Balance Report</a
>
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Contingent Expense')" (click)="glMenuOpen = false"
><fa-icon icon="exclamation-circle" class="subledger-icon"></fa-icon> Contingent Expense</a
<a
class="subledger-link"
[class.hidden]="!matchesGlSearch('Contingent Liabilities')"
routerLink="/gl/reports/contingent-liabilities"
(click)="glMenuOpen = false"
><fa-icon icon="exclamation-circle" class="subledger-icon"></fa-icon> Contingent Liabilities</a
>
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Banker Head Report')" (click)="glMenuOpen = false"
><fa-icon icon="university" class="subledger-icon"></fa-icon> Banker Head Report</a
@@ -365,6 +365,21 @@
</mat-form-field>
</form>
}
<!-- Compliance / AML Action Triage -->
<div class="compliance-triage" style="margin-top: 16px; padding: 12px; background: #fff8f8; border: 1px dashed #f5c2c2; border-radius: 8px;">
<h4 style="margin: 0 0 8px; font-weight: 600; color: #b91c1c; display: flex; align-items: center; gap: 6px;">
Compliance & AML Triage
</h4>
<div style="display: flex; flex-direction: column; gap: 8px;">
<mat-checkbox [(ngModel)]="flagAsSuspicious" [ngModelOptions]="{standalone: true}">
Flag as suspicious (escalate to STR cases under RBI compliance)
</mat-checkbox>
<mat-checkbox [(ngModel)]="markAsWhitelisted" [ngModelOptions]="{standalone: true}">
Mark as whitelisted (prevent future AML alerts for this account)
</mat-checkbox>
</div>
</div>
}
<div #lightboxRoot style="display: none"></div>
@@ -131,6 +131,90 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
/** 'any' = mode of operation lets any one signer sign (Either/Anyone or Survivor); 'all' = every mandatory signer must sign. */
signerSelectionMode: 'any' | 'all' = 'any';
manualOverrideChecked = false;
flagAsSuspicious = false;
markAsWhitelisted = false;
handleComplianceActions(): void {
if (this.flagAsSuspicious) {
const existingStrData = localStorage.getItem('fiu_str_cases');
let cases: any[] = [];
if (existingStrData) {
try {
cases = JSON.parse(existingStrData);
} catch (e) {
cases = [];
}
}
const newRef = 101 + cases.length;
const newCase: any = {
reportType: 'STR',
reportRefNo: newRef,
branchCode: this.item.presentingBranchCode || '1',
accountNumber: this.item.accountNo || this.item.accountId || '110001101000838',
txnBranchCode: this.item.presentingBranchCode || '1',
depositOrWithdrawal: 'Withdrawal',
txnId: this.item.id,
txnDate: new Date().toLocaleDateString('en-GB').replace(/\//g, '-'),
txnTime: new Date().toLocaleTimeString('en-US', { hour12: false }),
txnAmount: this.item.amount,
instrumentType: 'Cheque',
instrumentId: this.item.chequeNumber,
nonCustomerRef: '',
relationshipFlag: 'Customer',
thirdPartyPan: '',
declaration: 'NA',
customerName: this.item.clientName || 'UNKNOWN CLIENT',
detectedOn: new Date().toLocaleDateString('en-GB').replace(/\//g, '-'),
flaggedBy: 'CTS Clearing Officer',
riskCategory: 'unusual-pattern',
narrative: `CTS Cheque #${this.item.chequeNumber} for amount ₹${this.item.amount} flagged as suspicious by Clearing Officer during inward presentment review. Action: ${this.mode}. Reason/Notes: ${this.mode === 'RETURN' ? this.returnForm.value.reason : this.passForm.value.note || 'Manual compliance escalation'}`,
status: 'po-review'
};
cases.unshift(newCase);
localStorage.setItem('fiu_str_cases', JSON.stringify(cases));
const storedCtr = localStorage.getItem('fiu_ctr_txns');
if (storedCtr) {
try {
const ctrList = JSON.parse(storedCtr);
const matching = ctrList.find((t: any) => t.accountNumber === newCase.accountNumber);
if (matching) {
matching.complianceStatus = 'SUSPICIOUS';
localStorage.setItem('fiu_ctr_txns', JSON.stringify(ctrList));
}
} catch (e) {}
}
} else if (this.markAsWhitelisted) {
const existingWhitelist = localStorage.getItem('compliance_whitelisted_accounts');
let whitelisted: string[] = [];
if (existingWhitelist) {
try {
whitelisted = JSON.parse(existingWhitelist);
} catch (e) {
whitelisted = [];
}
}
const account = this.item.accountNo || this.item.accountId;
if (account && !whitelisted.includes(account.toString())) {
whitelisted.push(account.toString());
localStorage.setItem('compliance_whitelisted_accounts', JSON.stringify(whitelisted));
}
const storedCtr = localStorage.getItem('fiu_ctr_txns');
if (storedCtr) {
try {
const ctrList = JSON.parse(storedCtr);
const matching = ctrList.find((t: any) => t.accountNumber === (account ? account.toString() : ''));
if (matching) {
matching.complianceStatus = 'WHITELISTED';
localStorage.setItem('fiu_ctr_txns', JSON.stringify(ctrList));
}
} catch (e) {}
}
}
}
/** Every automatic validation rule InwardClearingValidationService already computes, shown so the clerk sees WHY, not just a decision. */
readonly validationRules: { key: string; label: string; goodWhenTrue: boolean }[] = [
@@ -652,6 +736,7 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
this.ctsService.passInwardItem(this.item.id, payload).subscribe({
next: () => {
this.busy = false;
this.handleComplianceActions();
this.notify('Cheque passed');
this.dialogRef.close({ actioned: true });
},
@@ -676,6 +761,7 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
this.ctsService.returnInwardItem(this.item.id, payload).subscribe({
next: () => {
this.busy = false;
this.handleComplianceActions();
this.notify('Cheque returned');
this.dialogRef.close({ actioned: true });
},
+3 -3
View File
@@ -81,7 +81,7 @@ export interface DeafEligibleAccount {
/** Form I — Dec 2025 cycle */
export const FORM_I: DeafForm = {
bankName: 'Bharat Urban Bank Ltd, Mumbai Main Branch',
bankName: 'CBSNexora Bank Ltd, Mumbai Main Branch',
bankDeafCode: '1100',
remittedThroughSponsor: 'Y',
sponsorBankName: 'State Bank of India',
@@ -170,7 +170,7 @@ export const FORM_I: DeafForm = {
/** Form II — Jan 2026 (head office consolidated) */
export const FORM_II: DeafForm = {
bankName: 'Bharat Urban Bank Ltd, Head Office',
bankName: 'CBSNexora Bank Ltd, Head Office',
bankDeafCode: '1100',
remittedThroughSponsor: 'Y',
sponsorBankName: 'State Bank of India',
@@ -271,7 +271,7 @@ export const REFUND_CLAIMS: {
auditor: string;
rows: DeafClaim[];
} = {
bankName: 'Bharat Urban Bank Ltd',
bankName: 'CBSNexora Bank Ltd',
bankDeafCode: '1100',
currentAcWithRbi: '03010200099001',
sponsorBank: 'State Bank of India',
+1 -1
View File
@@ -53,7 +53,7 @@ export interface SahayDepositor {
}
export const DICGC_BANK = {
name: 'Bharat Urban Bank Ltd',
name: 'CBSNexora Bank Ltd',
branch: 'Head Office',
reportAsOn: '29-01-2026',
printDate: '29-01-2026 04:19 PM',
+732 -300
View File
File diff suppressed because it is too large Load Diff
+516 -175
View File
@@ -14,12 +14,25 @@
<div>
<h2 class="fiu-head-t">Financial Intelligence Unit – India (FIU-IND)</h2>
<div class="fiu-head-s">
{{ bank.name }} &middot; {{ bank.branch }} &middot; Reporting Month <strong>{{ bank.reportingMonth }}</strong>
{{ bank.name }} &middot; {{ scopeName }} &middot; Reporting Month <strong>{{ bank.reportingMonth }}</strong>
&middot; PMLA 2002 / FINnet 2.0
</div>
</div>
</div>
<div class="fiu-head-meta">
<div>
<span class="fiu-meta-l">Branch</span>
@if (isHeadOffice) {
<select class="fiu-inp" [ngModel]="branchFilter" (ngModelChange)="setBranch($event)">
<option value="">All Branches</option>
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
}
</select>
} @else {
<span class="fiu-meta-v">{{ scopeName }}</span>
}
</div>
<div>
<span class="fiu-meta-l">Principal Officer</span><span class="fiu-meta-v">{{ bank.principalOfficer }}</span>
</div>
@@ -71,17 +84,19 @@
<div class="fiu-kpi fiu-kpi--amber">
<div class="fiu-kpi-ic"><fa-icon icon="exclamation-triangle"></fa-icon></div>
<div>
<div class="fiu-kpi-n">{{ pendingStr | number }}</div>
<div class="fiu-kpi-n">{{ liveStrPending | number }}</div>
<div class="fiu-kpi-l">STR Pending</div>
<div class="fiu-kpi-sub">draft / PO review</div>
<div class="fiu-kpi-sub">
{{ liveStrOpen }} to investigate &middot; {{ liveStrAwaitingFiling }} awaiting filing
</div>
</div>
</div>
<div class="fiu-kpi fiu-kpi--green">
<div class="fiu-kpi-ic"><fa-icon icon="check-circle"></fa-icon></div>
<div>
<div class="fiu-kpi-n">{{ filedStr | number }}</div>
<div class="fiu-kpi-n">{{ liveStrFiled | number }}</div>
<div class="fiu-kpi-l">STR Filed</div>
<div class="fiu-kpi-sub">acknowledged by FIU</div>
<div class="fiu-kpi-sub">{{ liveStrCleared }} concluded clear</div>
</div>
</div>
<div class="fiu-kpi fiu-kpi--purple">
@@ -94,6 +109,52 @@
</div>
</div>
<!-- WHEN TO REPORT — the five PMLA obligations -->
<div class="ob-block">
<div class="ob-head">
<div>
<h3 class="ob-title"><fa-icon icon="gavel"></fa-icon> When to report to FIU-IND</h3>
<p class="ob-sub">
The five reports a reporting entity owes under the Prevention of Money Laundering Act. All are filed
electronically through the FINnet 2.0 portal.
</p>
</div>
</div>
<div class="ob-grid">
@for (o of obligations; track o.code) {
<div class="ob-card" [class.ob-card--off]="!o.handled">
<div class="ob-card-h">
<fa-icon [icon]="o.icon" class="ob-ic"></fa-icon>
<span class="ob-code">{{ o.code }}</span>
<span class="ob-name">{{ o.name }}</span>
@if (o.handled) {
<span class="ob-flag ob-flag--on">Filed here</span>
} @else {
<span class="ob-flag">Tracked</span>
}
</div>
<div class="ob-row">
<label>Report when</label><span>{{ o.trigger }}</span>
</div>
<div class="ob-row">
<label>Deadline</label><span class="ob-due">{{ o.deadline }}</span>
</div>
<div class="ob-note">{{ o.note }}</div>
@if (o.code === 'CTR') {
<button class="dc-btn" (click)="showTab('ctr')">
<fa-icon icon="external-link-alt"></fa-icon> Open CTR
</button>
} @else if (o.code === 'STR') {
<button class="dc-btn" (click)="showTab('str')">
<fa-icon icon="external-link-alt"></fa-icon> Open STR
</button>
}
</div>
}
</div>
</div>
<!-- Compliance cards -->
<div class="fiu-compliance">
<div class="dc-card">
@@ -112,7 +173,12 @@
</div>
<div class="dc-meta">Filing window: <strong>within 7 days of detection</strong></div>
<div class="dc-meta">
Pending: <strong>{{ pendingStr }}</strong> &middot; Filed: <strong>{{ filedStr }}</strong>
To investigate: <strong>{{ liveStrOpen }}</strong> &middot; Awaiting filing:
<strong>{{ liveStrAwaitingFiling }}</strong>
</div>
<div class="dc-meta">
Filed: <strong>{{ liveStrFiled }}</strong> &middot; Concluded clear:
<strong>{{ liveStrCleared }}</strong>
</div>
<button class="dc-btn" (click)="showTab('str')"><fa-icon icon="external-link-alt"></fa-icon> Open</button>
</div>
@@ -129,21 +195,118 @@
<!-- CTR -->
@if (activeTab === 'ctr') {
<div class="fiu-section">
<!-- LIVE: cash-related AML alerts awaiting AML Officer review -->
<!-- FINNET FILE SEQUENCE — the CTR submission, in prescribed order -->
<div class="seq-block">
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>CTR Submission Files — FINnet 2.0</h3>
<p>
The CTR is built in this order: the account is described first, then who is behind it, then the two
transaction files. Every grid below is the exact content of its CSV. The customer cards (KC1 / KC2) travel
with the same bundle and are worked on the Customer Cards tab.
</p>
</div>
<button class="fiu-btn fiu-btn-xls" (click)="exportAll()">
<fa-icon icon="file-csv"></fa-icon> Download full bundle
</button>
</div>
<!-- Scope: these controls decide which transactions the six files carry. -->
<div class="fiu-filters">
<div class="fiu-filter">
<label>Type</label>
<select class="fiu-inp" [(ngModel)]="ctrFilterMode">
<option value="">All</option>
<option value="Deposit">Deposit</option>
<option value="Withdrawal">Withdrawal</option>
</select>
</div>
<div class="fiu-filter">
<label>Account contains</label>
<input class="fiu-inp" type="text" [(ngModel)]="ctrFilterAccount" placeholder="A/c partial" />
</div>
<div class="fiu-filter">
<label>Min Amount (&#8377;)</label>
<input class="fiu-inp" type="number" [(ngModel)]="ctrFilterMinAmount" placeholder="0" />
</div>
<button class="fiu-btn fiu-btn-g" (click)="resetCtrFilters()">Reset</button>
</div>
<!-- Step rail -->
<div class="seq-rail">
@for (f of finnetSequence; track f.key) {
<button class="seq-step" [class.active]="activeFinnetFile === f.key" (click)="showFinnetFile(f.key)">
<span class="seq-n">{{ f.step }}</span>
<span class="seq-txt">
<strong>{{ f.name }}</strong>
<em>{{ f.table.rows.length }} {{ f.table.rows.length === 1 ? 'row' : 'rows' }}</em>
</span>
</button>
@if (!$last) {
<fa-icon icon="chevron-right" class="seq-arrow"></fa-icon>
}
}
</div>
<!-- Open file -->
<div class="seq-panel">
<div class="seq-panel-h">
<div>
<h4>Step {{ openFinnetFile.step }} — {{ openFinnetFile.title }}</h4>
<p>{{ openFinnetFile.purpose }}</p>
</div>
<button class="fiu-btn fiu-btn-csv" (click)="downloadOpenFinnetFile()">
<fa-icon icon="file-csv"></fa-icon> {{ openFinnetFile.name }}.csv
</button>
</div>
<div class="seq-tbl-wrap">
<table class="seq-tbl">
<thead>
<tr>
<th class="seq-rownum">#</th>
@for (h of openFinnetFile.table.headers; track $index) {
<th>{{ h }}</th>
}
</tr>
</thead>
<tbody>
@for (r of openFinnetFile.table.rows; track $index) {
<tr>
<td class="seq-rownum">{{ $index + 1 }}</td>
@for (c of r; track $index) {
<td>{{ c }}</td>
}
</tr>
} @empty {
<tr>
<td [attr.colspan]="openFinnetFile.table.headers.length + 1" class="seq-empty">
@if (openFinnetFile.key === 'tc1') {
No ATM or card transactions match the current scope. When the bank books none at all, TC1 is
still submitted as a header-only file — that is what FIU-IND expects.
} @else {
No rows for the current filters.
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
}
<!-- STR -->
@if (activeTab === 'str') {
<div class="fiu-section">
<!-- LIVE: suspicious AML alerts awaiting AML Officer review (Structuring, PEP, etc.) -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>Live Cash Alerts</h3>
<p>
Raised by the transaction-monitoring rule engine — review and whitelist or escalate to Principal Officer
</p>
<h3>Live AML Alerts (Awaiting Triage)</h3>
<p>Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate</p>
</div>
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter" (ngModelChange)="loadAmlAlerts()">
<option value="">All statuses</option>
<option value="RAISED">Raised</option>
<option value="UNDER_REVIEW">Under Review</option>
<option value="WHITELISTED">Whitelisted</option>
<option value="ESCALATED">Escalated</option>
</select>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
@@ -156,12 +319,13 @@
<th class="ta-r">Amount</th>
<th>Raised On</th>
<th>Status</th>
<th class="ta-c">Routes To</th>
<th>Notes</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (alert of amlAlerts; track alert.id) {
@for (alert of amlSuspiciousAlerts; track alert.id) {
<tr>
<td>{{ alert.id }}</td>
<td>{{ alert.clientDisplayName }}</td>
@@ -172,6 +336,9 @@
<td>
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
</td>
<td class="ta-c">
<span [class]="alertRouteClass(alert.status, false)">{{ alertRouteLabel(alert.status, false) }}</span>
</td>
<td>
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
<input
@@ -195,153 +362,20 @@
</tr>
} @empty {
<tr>
<td colspan="9" class="fiu-empty">No live alerts.</td>
<td colspan="10" class="fiu-empty">No suspicious behavioral alerts.</td>
</tr>
}
</tbody>
</table>
</div>
<p class="fiu-tbl-legend">
<span class="report-pill report-pending">Pending</span> awaiting Whitelist/Escalate decision &middot;
<span class="report-pill report-cleared">Cleared</span> whitelisted — no report filed &middot;
<span class="report-pill report-str">→ STR</span> escalated to Principal Officer as an STR case
</p>
<!-- LIVE: monthly CTR reports (mechanical, threshold-based) -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>Monthly CTR Reports</h3>
<p>
Threshold: <strong>&#8377;{{ bank.cashThreshold | number }}</strong> per single txn / connected aggregate
per month
</p>
</div>
<div class="fiu-action-group">
<input class="fiu-inp" type="month" [(ngModel)]="ctrGenerateMonth" />
<button class="fiu-btn fiu-btn-g" *mifosxHasPermission="'REVIEW_AML_ALERT'" (click)="generateCtrReport()">
Generate
</button>
</div>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>Month</th>
<th>Generated On</th>
<th class="ta-c">Records</th>
<th>Status</th>
<th>Submitted On</th>
</tr>
</thead>
<tbody>
@for (r of amlCtrReports; track r.id) {
<tr>
<td>{{ r.reportMonth }}</td>
<td>{{ r.generatedOn | date: 'medium' }}</td>
<td class="ta-c">{{ r.recordCount }}</td>
<td>
<span [class]="amlStatusClass(r.status)">{{ r.status }}</span>
</td>
<td>{{ r.submittedOn ? (r.submittedOn | date: 'medium') : '—' }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="fiu-empty">No CTR reports generated yet.</td>
</tr>
}
</tbody>
</table>
</div>
<hr style="border: 0; border-top: 1px solid var(--f-g200); margin: 8px 0" />
<!-- FINnet export template (separate concern — TC1/TC2 file-format generation) -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>FINnet TC2 Export Template</h3>
<p>
All cash transactions ≥ &#8377;{{ bank.cashThreshold | number }} (single or aggregate per month per account)
</p>
</div>
<button class="fiu-btn fiu-btn-csv" (click)="exportTc2()">
<fa-icon icon="file-csv"></fa-icon> Download TC2.csv
</button>
</div>
<div class="fiu-filters">
<div class="fiu-filter">
<label>Type</label>
<select class="fiu-inp" [(ngModel)]="ctrFilterMode">
<option value="">All</option>
<option value="Deposit">Deposit</option>
<option value="Withdrawal">Withdrawal</option>
</select>
</div>
<div class="fiu-filter">
<label>Account contains</label>
<input class="fiu-inp" type="text" [(ngModel)]="ctrFilterAccount" placeholder="A/c partial" />
</div>
<div class="fiu-filter">
<label>Min Amount (&#8377;)</label>
<input class="fiu-inp" type="number" [(ngModel)]="ctrFilterMinAmount" placeholder="0" />
</div>
<button class="fiu-btn fiu-btn-g" (click)="resetCtrFilters()">Reset</button>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th class="ta-c">Ref</th>
<th>Account</th>
<th>Customer</th>
<th class="ta-c">Type</th>
<th>Date / Time</th>
<th class="ta-r">Amount</th>
<th>Instrument</th>
<th>Branch</th>
<th class="ta-c">Relationship</th>
</tr>
</thead>
<tbody>
@for (t of filteredCtr; track t.txnId) {
<tr>
<td class="ta-c">{{ t.reportRefNo }}</td>
<td class="mono">{{ t.accountNumber }}</td>
<td>{{ t.customerName }}</td>
<td class="ta-c">
<span
class="dir-pill"
[class.deposit]="t.depositOrWithdrawal === 'Deposit'"
[class.withdrawal]="t.depositOrWithdrawal === 'Withdrawal'"
>
{{ t.depositOrWithdrawal }}
</span>
</td>
<td>
{{ t.txnDate }} <span class="muted">{{ t.txnTime }}</span>
</td>
<td class="ta-r mono">
<strong>&#8377;{{ t.txnAmount | number: '1.0-0' }}</strong>
</td>
<td>
{{ t.instrumentType }} <span class="muted">{{ t.instrumentId }}</span>
</td>
<td class="mono">{{ t.branchCode }}</td>
<td class="ta-c">
<span class="rel-pill" [class.non-cust]="t.relationshipFlag === 'Non-Customer'">{{
t.relationshipFlag
}}</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="fiu-empty">No transactions match the filters.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- STR -->
@if (activeTab === 'str') {
<div class="fiu-section">
<!-- LIVE: STR cases with full Principal Officer actions -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
@@ -407,6 +441,17 @@
<button class="fiu-btn fiu-btn-g" (click)="closeStrCaseDetail()">Close</button>
</div>
<div
class="case-outcome"
[class.case-outcome--clear]="selectedStrCase.status === 'CONCLUDED_CLEAR'"
[class.case-outcome--suspicious]="
selectedStrCase.status === 'CONCLUDED_SUSPICIOUS' || selectedStrCase.status === 'FILED'
"
>
<span [class]="amlStatusClass(selectedStrCase.status)">{{ selectedStrCase.status }}</span>
<span>{{ strCaseOutcome(selectedStrCase) }}</span>
</div>
<div class="case-form" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<div class="form-row">
<label for="fiuInvestigationNotes">Investigation notes (source-of-transaction history reviewed)</label>
@@ -416,17 +461,99 @@
<label for="fiuSourceOfFundsNotes">Source-of-funds notes</label>
<textarea id="fiuSourceOfFundsNotes" rows="2" [(ngModel)]="concludeSourceOfFundsNotes"></textarea>
</div>
<div class="form-row conclusion-row">
<label>
<input type="radio" name="fiuConclusion" value="NOT_SUSPICIOUS" [(ngModel)]="concludeConclusion" />
Not suspicious (whitelist)
</label>
<label>
<input type="radio" name="fiuConclusion" value="SUSPICIOUS" [(ngModel)]="concludeConclusion" />
Suspicious (file STR)
</label>
<!-- Verification checklist — the verdict is derived, not chosen. -->
<div class="checklist">
<div class="checklist-head">
<span>Principal Officer verification — mark each point as found</span>
<span class="checklist-progress" [class.done]="allAnswered">
{{ answeredCount }} / {{ strChecklist.length }} answered
</span>
</div>
<table class="checklist-tbl">
<thead>
<tr>
<th>Verification point</th>
<th class="ta-c col-yes">Genuine</th>
<th class="ta-c col-no">Not satisfied</th>
</tr>
</thead>
<tbody>
@for (item of strChecklist; track item.key) {
<tr
[class.row-yes]="strFindings[item.key] === true"
[class.row-no]="strFindings[item.key] === false"
[class.row-pending]="strFindings[item.key] === null"
>
<td>
<div class="chk-label">{{ item.label }}</div>
<div class="chk-hint">{{ item.hint }}</div>
</td>
<td class="ta-c">
<button
type="button"
class="chk-box chk-box--yes"
[class.on]="strFindings[item.key] === true"
[attr.aria-pressed]="strFindings[item.key] === true"
[attr.aria-label]="'Mark ' + item.label + ' as genuine'"
(click)="setFinding(item.key, true)"
>
<fa-icon icon="check-circle"></fa-icon>
</button>
</td>
<td class="ta-c">
<button
type="button"
class="chk-box chk-box--no"
[class.on]="strFindings[item.key] === false"
[attr.aria-pressed]="strFindings[item.key] === false"
[attr.aria-label]="'Mark ' + item.label + ' as not satisfied'"
(click)="setFinding(item.key, false)"
>
<fa-icon icon="exclamation-triangle"></fa-icon>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<button class="fiu-btn fiu-btn-xml" (click)="concludeStrCase()">Conclude</button>
<!-- Derived verdict -->
<div
class="verdict"
[class.verdict--clear]="allAnswered && derivedConclusion === 'NOT_SUSPICIOUS'"
[class.verdict--suspicious]="allAnswered && derivedConclusion === 'SUSPICIOUS'"
>
@if (!allAnswered) {
<fa-icon icon="info-circle"></fa-icon>
<span>
Answer every verification point to determine the outcome —
{{ strChecklist.length - answeredCount }} still pending.
</span>
} @else if (derivedConclusion === 'NOT_SUSPICIOUS') {
<fa-icon icon="check-circle"></fa-icon>
<span>
<strong>Not suspicious.</strong> All {{ strChecklist.length }} points found genuine — the case will be
closed and nothing filed with FIU-IND.
</span>
} @else {
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>
<strong>Suspicious — STR must be filed.</strong>
{{ failedFindings.length }} of {{ strChecklist.length }} points not satisfied:
{{ failedFindings[0].label }}
@if (failedFindings.length > 1) {
<span> and {{ failedFindings.length - 1 }} more</span>
}
.
</span>
}
</div>
<button class="fiu-btn fiu-btn-xml" [disabled]="!allAnswered" (click)="concludeStrCase()">
Conclude as
{{ allAnswered ? (derivedConclusion === 'SUSPICIOUS' ? 'Suspicious' : 'Not Suspicious') : '…' }}
</button>
@if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') {
<div class="file-str-row">
@@ -457,8 +584,9 @@
<option value="">All</option>
<option value="draft">Draft</option>
<option value="po-review">PO Review</option>
<option value="awaiting-filing">Awaiting Filing</option>
<option value="filed">Filed</option>
<option value="rejected">Rejected</option>
<option value="rejected">Closed — Clean</option>
</select>
</div>
<div class="fiu-filter">
@@ -478,7 +606,12 @@
<div class="fiu-str-list">
@for (s of filteredStr; track s.reportRefNo) {
<div class="str-card" [class.str-card--filed]="s.status === 'filed'">
<div
class="str-card"
[id]="'str-card-' + s.reportRefNo"
[class.str-card--filed]="s.status === 'filed'"
[class.str-card--highlight]="highlightedStrRef === s.reportRefNo"
>
<div class="str-card-h">
<div class="str-card-id">
<span class="str-ref">STR-{{ s.reportRefNo }}</span>
@@ -510,6 +643,9 @@
<div>
<label>Instrument</label><span>{{ s.instrumentType }} {{ s.instrumentId }}</span>
</div>
<div>
<label>Branch</label><span class="mono">{{ s.branchCode }} {{ branchNameFor(s.branchCode) }}</span>
</div>
</div>
<div class="str-narrative">
<label>Narrative</label>
@@ -521,6 +657,208 @@
<p>{{ s.poRemarks }}</p>
</div>
}
@if (s.poFinding) {
<div class="str-finding" [class.str-finding--suspicious]="s.poFinding === 'SUSPICIOUS'">
<label>
<fa-icon [icon]="s.poFinding === 'CLEAN' ? 'check-circle' : 'exclamation-triangle'"></fa-icon>
Principal Officer Finding
</label>
<p>{{ poFindingLabel(s.poFinding) }}</p>
</div>
}
<!-- Concluded suspicious but not yet submitted to FIU-IND -->
@if (s.status === 'awaiting-filing') {
<div class="str-filing" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<label>
<fa-icon icon="exclamation-triangle"></fa-icon> Awaiting submission to FIU-IND — due within 7 days
of {{ s.detectedOn }}
</label>
<p class="filing-hint">
Generate the FINnet STR XML, upload it on the FIU-IND portal under
{{ bank.principalOfficer }}'s digital signature, then record the acknowledgement number here.
</p>
<div class="filing-row">
<button class="fiu-btn" (click)="exportSingleStrXml(s)">
<fa-icon icon="file-alt"></fa-icon> Generate STR XML
</button>
<input
class="fiu-inp"
type="text"
placeholder="FIU-IND acknowledgement no."
[(ngModel)]="localFileRefs[s.reportRefNo]"
/>
<button class="fiu-btn fiu-btn-xml" (click)="fileLocalStr(s)">Mark STR Filed</button>
</div>
</div>
}
@if (s.status === 'filed' && s.fiuReferenceNo) {
<div class="str-filed-ack">
<fa-icon icon="check-circle"></fa-icon>
<span>
Filed with FIU-IND on <strong>{{ s.filedOn }}</strong> &middot; acknowledgement
<strong class="mono">{{ s.fiuReferenceNo }}</strong>
</span>
</div>
}
@if (!s.poFinding && investigatingStr?.reportRefNo !== s.reportRefNo) {
<div class="str-po-action" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<label>Principal Officer investigation — is the source of funds legitimate?</label>
<button class="fiu-btn fiu-btn-xml" (click)="investigateLocalStr(s)">
<fa-icon icon="search"></fa-icon> Investigate this case
</button>
</div>
}
<!-- Investigation workspace for this case -->
@if (investigatingStr?.reportRefNo === s.reportRefNo) {
<div class="str-investigation" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<div class="inv-head">
<span>Principal Officer investigation — STR-{{ s.reportRefNo }}</span>
<button class="fiu-link-btn fiu-link-btn--muted" (click)="closeLocalStrInvestigation()">
Cancel
</button>
</div>
<!-- Step 1 — call for evidence -->
<div class="inv-step">
<div class="inv-step-t"><span class="inv-num">1</span> Documentary evidence called for</div>
<div class="inv-grid">
<div class="form-row">
<label for="invDocType">Document requested from the customer</label>
<select id="invDocType" class="fiu-inp" [(ngModel)]="evidenceDocType">
<option value="">Select document…</option>
@for (d of evidenceDocTypes; track d) {
<option [value]="d">{{ d }}</option>
}
</select>
</div>
<div class="form-row">
<label for="invReceived">Was it produced?</label>
<select id="invReceived" class="fiu-inp" [(ngModel)]="evidenceReceived">
<option value="">Select…</option>
<option value="YES">Yes — complete</option>
<option value="PARTIAL">Partially — incomplete</option>
<option value="NO">No — not produced</option>
</select>
</div>
<div class="form-row">
<label for="invRef">Document / reference number</label>
<input id="invRef" class="fiu-inp" type="text" [(ngModel)]="evidenceReference" />
</div>
</div>
</div>
<!-- Step 2 — customer's account of the funds -->
<div class="inv-step">
<div class="inv-step-t"><span class="inv-num">2</span> Customer’s explanation of the source</div>
<textarea
rows="2"
[(ngModel)]="customerExplanation"
placeholder="e.g. Proceeds of a flat sold in June; sale deed produced and matched to the deposit amount and date."
></textarea>
</div>
<!-- Step 3 — verification checklist -->
<div class="inv-step">
<div class="inv-step-t">
<span class="inv-num">3</span> Verification checklist
<span class="checklist-progress" [class.done]="allAnswered">
{{ answeredCount }} / {{ strChecklist.length }} answered
</span>
</div>
<table class="checklist-tbl">
<thead>
<tr>
<th>Verification point</th>
<th class="ta-c col-yes">Genuine</th>
<th class="ta-c col-no">Not satisfied</th>
</tr>
</thead>
<tbody>
@for (item of strChecklist; track item.key) {
<tr
[class.row-yes]="strFindings[item.key] === true"
[class.row-no]="strFindings[item.key] === false"
[class.row-pending]="strFindings[item.key] === null"
>
<td>
<div class="chk-label">{{ item.label }}</div>
<div class="chk-hint">{{ item.hint }}</div>
</td>
<td class="ta-c">
<button
type="button"
class="chk-box chk-box--yes"
[class.on]="strFindings[item.key] === true"
[attr.aria-label]="'Mark ' + item.label + ' as genuine'"
(click)="setFinding(item.key, true)"
>
<fa-icon icon="check-circle"></fa-icon>
</button>
</td>
<td class="ta-c">
<button
type="button"
class="chk-box chk-box--no"
[class.on]="strFindings[item.key] === false"
[attr.aria-label]="'Mark ' + item.label + ' as not satisfied'"
(click)="setFinding(item.key, false)"
>
<fa-icon icon="exclamation-triangle"></fa-icon>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Derived outcome -->
<div
class="verdict"
[class.verdict--clear]="canConcludeLocal && derivedConclusion === 'NOT_SUSPICIOUS'"
[class.verdict--suspicious]="canConcludeLocal && derivedConclusion === 'SUSPICIOUS'"
>
@if (!canConcludeLocal) {
<fa-icon icon="info-circle"></fa-icon>
<span>
Complete all three steps to determine the outcome.
@if (!evidenceRecorded) {
<span> Evidence not yet recorded.</span>
}
@if (!allAnswered) {
<span> {{ strChecklist.length - answeredCount }} verification point(s) pending.</span>
}
</span>
} @else if (derivedConclusion === 'NOT_SUSPICIOUS') {
<fa-icon icon="check-circle"></fa-icon>
<span>
<strong>Source of funds established.</strong> Case closes as clean — the transaction stays in
the CTR report and no STR is filed with FIU-IND.
</span>
} @else {
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>
<strong>Source of funds not established.</strong> {{ failedFindings.length }} point(s)
unsatisfied — an STR will be filed with FIU-IND in addition to the CTR.
</span>
}
</div>
<button class="fiu-btn fiu-btn-xml" [disabled]="!canConcludeLocal" (click)="concludeLocalStr()">
{{
canConcludeLocal
? derivedConclusion === 'SUSPICIOUS'
? 'Conclude — file STR with FIU-IND'
: 'Conclude — close as clean (CTR only)'
: 'Conclude'
}}
</button>
</div>
}
</div>
</div>
} @empty {
@@ -626,10 +964,13 @@
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>FINnet 2.0 Submission Files</h3>
<p>6 CSV files in FIU-IND prescribed schema, plus STR XML</p>
<p>
6 CSV files in FIU-IND prescribed schema, plus STR XML. Counts reflect any filters set on the CTR and STR
tabs, so every file in the bundle covers the same transactions.
</p>
</div>
<button class="fiu-btn fiu-btn-xls" (click)="exportAll()">
<fa-icon icon="file-csv"></fa-icon> Download All
<fa-icon icon="file-csv"></fa-icon> Download All 7
</button>
</div>
@@ -641,7 +982,7 @@
</div>
<div class="file-card">
<div class="file-card-h"><fa-icon icon="file-csv"></fa-icon>TC2.csv</div>
<p>Branch / cash transactions ({{ ctrTxns.length }} rows)</p>
<p>Branch / cash transactions ({{ filteredCtr.length }} rows)</p>
<button class="fiu-btn fiu-btn-csv" (click)="exportTc2()">
<fa-icon icon="file-csv"></fa-icon> Download
</button>
@@ -676,7 +1017,7 @@
</div>
<div class="file-card file-card--xml">
<div class="file-card-h"><fa-icon icon="file-alt"></fa-icon>STR.xml</div>
<p>Suspicious Transaction Report ({{ filedStr + pendingStr }} suspects)</p>
<p>Suspicious Transaction Report ({{ strSuspects.length }} suspects — cases concluded clean are excluded)</p>
<button class="fiu-btn fiu-btn-xml" (click)="exportStrXml()">
<fa-icon icon="file-alt"></fa-icon> Download XML
</button>
+838
View File
@@ -386,6 +386,172 @@
align-self: end;
}
/* CTR outcome 1: source of funds evidenced at the counter — CTR only. */
.fiu-btn-evidence {
padding: 5px 10px;
font-size: 11px;
font-weight: 600;
color: var(--f-green);
border-color: var(--f-green);
background: var(--f-green-l);
&:hover {
background: var(--f-green);
border-color: var(--f-green);
color: #fff;
}
}
/* Inline evidence capture row inside the TC2 table. */
.evidence-row td {
background: var(--f-green-l) !important;
padding: 0 !important;
}
.evidence-panel {
padding: 12px 16px;
border-left: 3px solid var(--f-green);
}
.evidence-t {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 700;
color: #14532d;
margin-bottom: 10px;
}
.evidence-grid {
display: grid;
grid-template-columns: 2fr 1fr auto;
gap: 12px;
align-items: end;
}
.evidence-note {
margin: 8px 0 0;
font-size: 11px;
color: var(--f-g700);
}
@media (width <= 900px) {
.evidence-grid {
grid-template-columns: 1fr;
}
}
/* CTR outcome 2: evidence unavailable — refer the transaction onward to STR. */
.fiu-btn-refer {
padding: 5px 10px;
font-size: 11px;
font-weight: 600;
color: var(--f-red);
border-color: var(--f-red);
background: var(--f-red-l);
&:hover {
background: var(--f-red);
border-color: var(--f-red);
color: #fff;
}
}
.fiu-link-btn {
display: inline-block;
margin-top: 3px;
padding: 0;
border: none;
background: none;
font-family: inherit;
font-size: 11px;
font-weight: 600;
color: var(--f-red);
cursor: pointer;
text-decoration: underline;
&:hover {
color: #9a2f22;
}
&--muted {
color: var(--f-g500);
&:hover {
color: var(--f-red);
}
}
}
/* PRINCIPAL OFFICER INVESTIGATION */
.str-po-action {
background: var(--f-amber-l);
border: 1px dashed #fcd34d;
border-radius: 8px;
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 8px;
> label {
font-size: 10px;
font-weight: 700;
color: #92400e;
text-transform: uppercase;
letter-spacing: 0.05em;
}
}
.fiu-btn-clean {
background: var(--f-green);
border-color: var(--f-green);
color: #fff;
&:hover {
background: #166534;
border-color: #166534;
color: #fff;
}
}
.str-finding {
background: var(--f-green-l);
border-radius: 8px;
padding: 10px 14px;
label {
font-size: 10px;
font-weight: 700;
color: var(--f-green);
text-transform: uppercase;
letter-spacing: 0.05em;
display: flex;
align-items: center;
gap: 5px;
margin-bottom: 4px;
}
p {
margin: 0;
font-size: 13px;
color: #14532d;
line-height: 1.5;
}
&--suspicious {
background: var(--f-red-l);
label {
color: var(--f-red);
}
p {
color: #7f1d1d;
}
}
}
/* INPUTS */
.fiu-inp {
width: 100%;
@@ -538,6 +704,50 @@
}
}
.report-pill {
display: inline-block;
padding: 2px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
&.report-ctr {
background: var(--f-blue-l);
color: var(--f-blue);
}
&.report-str {
background: var(--f-red-l);
color: var(--f-red);
margin-left: 4px;
}
&.report-pending {
background: var(--f-amber-l);
color: var(--f-amber);
}
&.report-cleared {
background: var(--f-green-l);
color: var(--f-green);
}
}
.fiu-tbl-legend {
margin: -6px 2px 0;
font-size: 11px;
color: var(--f-g500);
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
.report-pill {
margin-left: 0;
}
}
.rel-pill {
display: inline-block;
padding: 2px 9px;
@@ -570,6 +780,11 @@
color: var(--f-amber);
}
&.st-awaiting-filing {
background: #ffedd5;
color: #9a3412;
}
&.st-filed {
background: var(--f-green-l);
color: var(--f-green);
@@ -697,6 +912,11 @@
&--filed {
border-color: #86efac;
}
&--highlight {
border-color: var(--f-red);
box-shadow: 0 0 0 3px rgb(192 57 43 / 20%);
}
}
.str-card-h {
@@ -956,6 +1176,29 @@
margin-bottom: 16px;
}
.case-outcome {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin: 12px 0;
padding: 10px 14px;
border-radius: 8px;
background: var(--f-g100);
font-size: 13px;
color: var(--f-g700);
&--clear {
background: var(--f-green-l);
color: #14532d;
}
&--suspicious {
background: var(--f-red-l);
color: #7f1d1d;
}
}
.case-form {
display: flex;
flex-direction: column;
@@ -995,6 +1238,303 @@
}
}
/* ── Principal Officer verification checklist ───────────────────────────── */
.checklist {
border: 1px solid var(--f-g200);
border-radius: 10px;
overflow: hidden;
background: #fff;
}
.checklist-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: var(--f-g50);
border-bottom: 1px solid var(--f-g200);
font-size: 12px;
font-weight: 700;
color: var(--f-g700);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.checklist-progress {
font-size: 11px;
font-weight: 700;
padding: 2px 10px;
border-radius: 999px;
background: var(--f-amber-l);
color: var(--f-amber);
text-transform: none;
letter-spacing: 0;
white-space: nowrap;
&.done {
background: var(--f-green-l);
color: var(--f-green);
}
}
.checklist-tbl {
width: 100%;
border-collapse: collapse;
th {
padding: 8px 12px;
font-size: 10px;
font-weight: 700;
color: var(--f-g500);
text-transform: uppercase;
letter-spacing: 0.05em;
background: var(--f-g50);
border-bottom: 1px solid var(--f-g200);
text-align: left;
white-space: nowrap;
}
th.col-yes {
color: var(--f-green);
width: 96px;
}
th.col-no {
color: var(--f-red);
width: 112px;
}
td {
padding: 9px 12px;
border-bottom: 1px solid var(--f-g100);
vertical-align: middle;
}
tr:last-child td {
border-bottom: none;
}
}
/* Answered rows tint so an unresolved finding is obvious at a glance. */
.row-yes td {
background: rgb(220 252 231 / 45%);
}
.row-no td {
background: rgb(253 236 234 / 60%);
}
.row-pending td {
background: #fff;
}
.chk-label {
font-size: 13px;
font-weight: 600;
color: var(--f-g900);
}
.chk-hint {
font-size: 11px;
color: var(--f-g500);
margin-top: 1px;
}
/* Two-sided toggle: one box on the genuine side, one on the not-satisfied side. */
.chk-box {
width: 30px;
height: 30px;
border-radius: 7px;
border: 2px solid var(--f-g300);
background: #fff;
color: var(--f-g300);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: all 0.15s;
&:hover {
transform: scale(1.08);
}
&--yes:hover {
border-color: var(--f-green);
color: var(--f-green);
}
&--no:hover {
border-color: var(--f-red);
color: var(--f-red);
}
&--yes.on {
border-color: var(--f-green);
background: var(--f-green);
color: #fff;
}
&--no.on {
border-color: var(--f-red);
background: var(--f-red);
color: #fff;
}
}
/* Concluded suspicious, still to be submitted on the FINnet portal. */
.str-filing {
background: #fff7ed;
border: 1px solid #fdba74;
border-radius: 8px;
padding: 10px 14px;
label {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
color: #9a3412;
text-transform: uppercase;
letter-spacing: 0.04em;
}
}
.filing-hint {
margin: 4px 0 8px;
font-size: 12px;
color: var(--f-g700);
}
.filing-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
.fiu-inp {
max-width: 260px;
}
}
.str-filed-ack {
display: flex;
align-items: center;
gap: 8px;
background: var(--f-green-l);
border: 1px solid #86efac;
border-radius: 8px;
padding: 9px 14px;
font-size: 12px;
color: #14532d;
}
/* ── CTR-referred case: Principal Officer investigation workspace ───────── */
.str-investigation {
margin-top: 12px;
border: 1px solid var(--f-purple);
border-radius: 10px;
background: #fdfcff;
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
}
.inv-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
font-size: 12px;
font-weight: 700;
color: var(--f-purple);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.inv-step {
display: flex;
flex-direction: column;
gap: 8px;
textarea {
width: 100%;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--f-g300);
font-family: inherit;
font-size: 13px;
}
}
.inv-step-t {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 700;
color: var(--f-g900);
}
.inv-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--f-purple);
color: #fff;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
}
.inv-grid {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 12px;
}
@media (width <= 900px) {
.inv-grid {
grid-template-columns: 1fr;
}
}
/* Derived verdict banner */
.verdict {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 15px;
border-radius: 9px;
font-size: 13px;
background: var(--f-g100);
border: 1px solid var(--f-g200);
color: var(--f-g700);
&--clear {
background: var(--f-green-l);
border-color: #86efac;
color: #14532d;
}
&--suspicious {
background: var(--f-red-l);
border-color: #fca5a5;
color: #7f1d1d;
}
}
.case-form .fiu-btn-xml:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.file-str-row {
display: flex;
align-items: center;
@@ -1009,3 +1549,301 @@
color: var(--f-g500);
}
}
/* ═══ WHEN TO REPORT — the five PMLA obligations ═══ */
.ob-block {
margin: 18px 0 20px;
}
.ob-head {
margin-bottom: 12px;
}
.ob-title {
margin: 0;
font-size: 15px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.ob-sub {
margin: 3px 0 0;
font-size: 12px;
color: #64748b;
max-width: 860px;
}
.ob-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 12px;
}
.ob-card {
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #1a237e;
border-radius: 11px;
padding: 13px 15px;
display: flex;
flex-direction: column;
gap: 7px;
/* Obligations the module only tracks read back a shade quieter than the
two it actually files, so the distinction is visible at a glance. */
&--off {
border-left-color: #cbd5e1;
background: #fcfcfd;
}
}
.ob-card-h {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.ob-ic {
color: #1a237e;
font-size: 15px;
}
.ob-code {
font-weight: 800;
font-size: 13px;
letter-spacing: 0.02em;
}
.ob-name {
font-size: 12px;
color: #334155;
font-weight: 600;
}
.ob-flag {
margin-left: auto;
font-size: 10px;
font-weight: 700;
padding: 1px 8px;
border-radius: 999px;
background: #f1f5f9;
color: #64748b;
white-space: nowrap;
&--on {
background: #dcfce7;
color: #15803d;
}
}
.ob-row {
display: flex;
flex-direction: column;
gap: 1px;
label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
}
span {
font-size: 12.5px;
color: #0f172a;
line-height: 1.45;
}
}
.ob-due {
font-weight: 700;
color: #b45309 !important;
}
.ob-note {
font-size: 11.5px;
color: #64748b;
font-style: italic;
border-top: 1px dashed #e2e8f0;
padding-top: 6px;
}
/* ═══ FINNET FILE SEQUENCE ═══ */
.seq-block {
margin-top: 26px;
padding-top: 18px;
border-top: 2px solid #e2e8f0;
}
.seq-rail {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.seq-step {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 7px 13px;
border: 1px solid #cbd5e1;
background: #fff;
border-radius: 9px;
cursor: pointer;
font-family: inherit;
text-align: left;
transition: all 0.15s;
&:hover {
border-color: #1a237e;
}
&.active {
background: #1a237e;
border-color: #1a237e;
.seq-n {
background: #fff;
color: #1a237e;
}
.seq-txt strong,
.seq-txt em {
color: #fff;
}
}
}
.seq-n {
display: inline-flex;
align-items: center;
justify-content: center;
width: 21px;
height: 21px;
border-radius: 50%;
background: #1a237e;
color: #fff;
font-size: 11px;
font-weight: 800;
flex: 0 0 21px;
}
.seq-txt {
display: flex;
flex-direction: column;
line-height: 1.25;
strong {
font-size: 12.5px;
color: #0f172a;
font-weight: 700;
}
em {
font-size: 10.5px;
color: #64748b;
font-style: normal;
}
}
.seq-arrow {
color: #cbd5e1;
font-size: 11px;
}
.seq-panel {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 11px;
overflow: hidden;
}
.seq-panel-h {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
padding: 12px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
flex-wrap: wrap;
h4 {
margin: 0;
font-size: 13.5px;
font-weight: 700;
}
p {
margin: 2px 0 0;
font-size: 12px;
color: #64748b;
max-width: 780px;
}
}
.seq-tbl-wrap {
max-height: 460px;
overflow: auto;
}
.seq-tbl {
width: 100%;
border-collapse: collapse;
font-size: 11.5px;
white-space: nowrap;
th {
position: sticky;
top: 0;
z-index: 1;
text-align: left;
padding: 8px 10px;
background: #eef2f7;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #334155;
border-bottom: 1px solid #cbd5e1;
}
td {
padding: 6px 10px;
border-bottom: 1px solid #f1f5f9;
font-family: 'DM Mono', monospace;
color: #0f172a;
}
tbody tr:hover td {
background: #f8fafc;
}
}
.seq-rownum {
color: #94a3b8 !important;
font-weight: 700;
text-align: right;
position: sticky;
left: 0;
background: #fff;
}
.seq-tbl thead .seq-rownum {
background: #eef2f7;
}
.seq-empty {
padding: 36px !important;
text-align: center;
color: #64748b;
font-family: inherit !important;
white-space: normal;
font-size: 12.5px;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,638 @@
/**
* 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 { GL_BRANCHES, GL_PERIOD } from '../gl-reports/gl-reports-data';
/**
* Transactions of customers who banked on a Form 97 / Form 98 declaration.
*
* Rule 114B of the Income-tax Rules lists transactions where quoting PAN is
* mandatory. A customer who has no PAN gives a declaration instead:
*
* Form 97 — person with no PAN (income may be taxable).
* Form 98 — person whose only income is agricultural and who has no PAN.
*
* The bank must be able to produce, for any period, every transaction put
* through on the strength of such a declaration. That is what this report is.
*/
export type FormType = 'FORM_60' | 'FORM_61';
/** Screen and workbook labels — this bank numbers the declarations 97 and 98. */
export const FORM_LABEL: Record<FormType, string> = {
FORM_60: 'Form 97',
FORM_61: 'Form 98'
};
export interface FormDeclarant {
/** Declaration acknowledgement number, as issued by the branch. */
ackNo: string;
formType: FormType;
name: string;
fatherName: string;
dateOfBirth: string;
address: string;
branchCode: string;
accountNo: string;
/** Why the customer has no PAN — printed on the declaration. */
reasonForNoPan: string;
estimatedIncome: number;
declaredOn: string;
aadhaarLast4: string;
}
export type TxnMode = 'CASH' | 'TRANSFER' | 'CLEARING' | 'NEFT' | 'RTGS' | 'UPI' | 'ATM';
export interface FormDeclarantTxn {
txnId: string;
ackNo: string;
date: string;
branchCode: string;
accountNo: string;
mode: TxnMode;
drCr: 'DR' | 'CR';
amount: number;
/** What the transaction was — a Rule 114B category or ordinary activity. */
nature: string;
/** Coarse bucket the nature belongs to, e.g. Cash, Clearing, Loan. */
group: string;
/** True when the nature is one of the categories listed in Rule 114B. */
rule114B: boolean;
particulars: string;
voucherNo: string;
}
/** Deterministic linear congruential generator — same figures on every load. */
function lcg(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function pick<T>(rand: () => number, list: T[]): T {
return list[Math.floor(rand() * list.length)];
}
function isoDate(d: Date): string {
const y = d.getUTCFullYear();
const m = `${d.getUTCMonth() + 1}`.padStart(2, '0');
const day = `${d.getUTCDate()}`.padStart(2, '0');
return `${y}-${m}-${day}`;
}
/**
* Thresholds that make a row worth a second look.
*
* `RULE_114B_CASH` is the cash-deposit limit above which a declaration is
* required at all; `CTR` is the aggregate cash figure that puts the customer
* into the monthly Cash Transaction Report.
*/
export const FORM_THRESHOLD = {
RULE_114B_CASH: 50_000,
CTR: 1_000_000
};
const FIRST_NAMES = [
'Ramesh',
'Sunita',
'Vitthal',
'Kamala',
'Ganpat',
'Shobha',
'Dnyaneshwar',
'Mangala',
'Bhaskar',
'Anita',
'Tukaram',
'Sarika',
'Nivrutti',
'Rekha',
'Popat',
'Chhaya',
'Ashok',
'Vaishali',
'Bapu',
'Nanda',
'Sitaram',
'Jayshree',
'Madhukar',
'Pushpa',
'Eknath',
'Sulochana',
'Baban',
'Indubai',
'Hanumant',
'Kalpana'
];
const SURNAMES = [
'Jadhav',
'Pawar',
'Shinde',
'Gaikwad',
'More',
'Kadam',
'Bhosale',
'Patil',
'Sawant',
'Chavan',
'Salunkhe',
'Mane',
'Deshmukh',
'Thorat',
'Nikam',
'Wagh',
'Kale',
'Ghadge',
'Bhoir',
'Tandel'
];
const VILLAGES = [
'Wadgaon',
'Kolhewadi',
'Shirgaon',
'Nandgaon',
'Pimpalgaon',
'Bhosari',
'Rajgurunagar',
'Talegaon',
'Karjat',
'Murbad',
'Shahapur',
'Vasind'
];
/** Reasons a Form 97 declarant gives — Form 98 has its own single reason. */
const NO_PAN_REASONS = [
'Applied for PAN, allotment awaited',
'Total income below taxable limit',
'Not required to file return of income',
'First-time account holder, PAN not yet obtained'
];
/**
* Every kind of transaction one of these customers puts through — not only the
* ones that triggered a declaration.
*
* `rule114B` marks the categories listed in Rule 114B, where quoting PAN (and
* therefore the Form 97/98 declaration) is mandatory above the limit. The rest
* are ordinary account activity, which the report still has to show: the point
* is the customer's whole picture, not just the declared slice.
*
* `drCr` is fixed where the category only goes one way; 'EITHER' lets the
* generator choose. `cashOnly` forces counter cash, `nonCash` excludes it.
*/
interface TxnKind {
nature: string;
group: string;
rule114B: boolean;
drCr: 'DR' | 'CR' | 'EITHER';
cashOnly?: boolean;
nonCash?: boolean;
/** Relative frequency — small routine postings outnumber big declared ones. */
weight: number;
/** Typical value band, before the occasional large-value tail. */
min: number;
max: number;
}
const TXN_KINDS: TxnKind[] = [
// ── Rule 114B categories: the transactions the declaration was taken for ──
{
nature: 'Cash deposit exceeding ₹50,000 in a day',
group: 'Cash',
rule114B: true,
drCr: 'CR',
cashOnly: true,
weight: 14,
min: 50_000,
max: 470_000
},
{
nature: 'Cash withdrawal exceeding ₹50,000',
group: 'Cash',
rule114B: true,
drCr: 'DR',
cashOnly: true,
weight: 8,
min: 50_000,
max: 380_000
},
{
nature: 'Opening of deposit account',
group: 'Account',
rule114B: true,
drCr: 'CR',
cashOnly: true,
weight: 3,
min: 5_000,
max: 50_000
},
{
nature: 'Term deposit exceeding ₹50,000',
group: 'Deposit',
rule114B: true,
drCr: 'DR',
weight: 5,
min: 50_000,
max: 900_000
},
{
nature: 'Purchase of demand draft in cash',
group: 'Remittance',
rule114B: true,
drCr: 'DR',
cashOnly: true,
weight: 4,
min: 50_000,
max: 260_000
},
{
nature: 'Sale/purchase of goods or services above ₹2,00,000',
group: 'Trade',
rule114B: true,
drCr: 'EITHER',
weight: 3,
min: 200_000,
max: 850_000
},
// ── Ordinary account activity ────────────────────────────────────────────
{
nature: 'Cash deposit',
group: 'Cash',
rule114B: false,
drCr: 'CR',
cashOnly: true,
weight: 16,
min: 500,
max: 49_900
},
{
nature: 'Cash withdrawal',
group: 'Cash',
rule114B: false,
drCr: 'DR',
cashOnly: true,
weight: 15,
min: 500,
max: 49_900
},
{
nature: 'Cheque deposit — outward clearing',
group: 'Clearing',
rule114B: false,
drCr: 'CR',
nonCash: true,
weight: 7,
min: 2_000,
max: 180_000
},
{
nature: 'Cheque paid — inward clearing',
group: 'Clearing',
rule114B: false,
drCr: 'DR',
nonCash: true,
weight: 6,
min: 2_000,
max: 160_000
},
{
nature: 'NEFT inward credit',
group: 'Remittance',
rule114B: false,
drCr: 'CR',
nonCash: true,
weight: 6,
min: 1_000,
max: 220_000
},
{
nature: 'NEFT outward remittance',
group: 'Remittance',
rule114B: false,
drCr: 'DR',
nonCash: true,
weight: 5,
min: 1_000,
max: 200_000
},
{
nature: 'RTGS inward credit',
group: 'Remittance',
rule114B: false,
drCr: 'CR',
nonCash: true,
weight: 2,
min: 200_000,
max: 1_200_000
},
{
nature: 'UPI / mobile banking transfer',
group: 'Digital',
rule114B: false,
drCr: 'EITHER',
nonCash: true,
weight: 10,
min: 100,
max: 24_000
},
{
nature: 'ATM cash withdrawal',
group: 'Digital',
rule114B: false,
drCr: 'DR',
cashOnly: true,
weight: 9,
min: 500,
max: 20_000
},
{
nature: 'Transfer between own accounts',
group: 'Transfer',
rule114B: false,
drCr: 'EITHER',
nonCash: true,
weight: 5,
min: 1_000,
max: 120_000
},
{
nature: 'Loan instalment (EMI) recovery',
group: 'Loan',
rule114B: false,
drCr: 'DR',
weight: 7,
min: 2_500,
max: 42_000
},
{
nature: 'Loan disbursement credited',
group: 'Loan',
rule114B: false,
drCr: 'CR',
nonCash: true,
weight: 2,
min: 50_000,
max: 700_000
},
{
nature: 'Interest credited on deposit',
group: 'Interest',
rule114B: false,
drCr: 'CR',
nonCash: true,
weight: 6,
min: 120,
max: 14_000
},
{
nature: 'Interest debited on loan',
group: 'Interest',
rule114B: false,
drCr: 'DR',
nonCash: true,
weight: 4,
min: 200,
max: 11_000
},
{
nature: 'Term deposit matured and credited',
group: 'Deposit',
rule114B: false,
drCr: 'CR',
nonCash: true,
weight: 3,
min: 25_000,
max: 620_000
},
{
nature: 'Recurring deposit instalment',
group: 'Deposit',
rule114B: false,
drCr: 'DR',
weight: 5,
min: 500,
max: 12_000
},
{
nature: 'Service charges / commission recovered',
group: 'Charges',
rule114B: false,
drCr: 'DR',
nonCash: true,
weight: 6,
min: 25,
max: 2_400
},
{
nature: 'Cheque return charges',
group: 'Charges',
rule114B: false,
drCr: 'DR',
nonCash: true,
weight: 2,
min: 100,
max: 900
},
{
nature: 'Locker rent recovered',
group: 'Charges',
rule114B: false,
drCr: 'DR',
nonCash: true,
weight: 1,
min: 800,
max: 4_500
},
{
nature: 'Dividend credited on shares',
group: 'Shares',
rule114B: false,
drCr: 'CR',
nonCash: true,
weight: 2,
min: 200,
max: 9_000
},
{
nature: 'Share capital subscription',
group: 'Shares',
rule114B: false,
drCr: 'DR',
weight: 2,
min: 500,
max: 25_000
}
];
/** Flattened by weight so a single draw picks a category at the right rate. */
const KIND_POOL: TxnKind[] = TXN_KINDS.flatMap((k) => Array<TxnKind>(k.weight).fill(k));
/** The distinct groups, for the report's category filter. */
export const TXN_GROUPS: string[] = [...new Set(TXN_KINDS.map((k) => k.group))].sort();
let declarantCache: FormDeclarant[] | null = null;
let txnCache: FormDeclarantTxn[] | null = null;
/**
* The customers who filed a declaration. Roughly one in five is a Form 98
* (agricultural income only), which matches the mix a semi-urban co-operative
* bank sees — the rural branches carry most of them.
*/
export function formDeclarants(): FormDeclarant[] {
if (declarantCache) {
return declarantCache;
}
const rand = lcg(31071998);
const branches = GL_BRANCHES.filter((b) => b.code !== 'HO');
const out: FormDeclarant[] = [];
for (let i = 0; i < 148; i++) {
const branch = branches[i % branches.length];
// Form 98 is the agricultural-income declaration, so it clusters in the
// outlying branches rather than being spread evenly.
const rural = branch.code === '0004' || branch.code === '0006';
const formType: FormType = rand() < (rural ? 0.42 : 0.08) ? 'FORM_61' : 'FORM_60';
const first = pick(rand, FIRST_NAMES);
const last = pick(rand, SURNAMES);
const seq = `${i + 1}`.padStart(3, '0');
// Declarations are taken in the run-up to and during the ledger period.
const declOffset = Math.floor(rand() * 150);
const declDate = new Date(`${GL_PERIOD.fyStart}T00:00:00Z`);
declDate.setUTCDate(declDate.getUTCDate() - 60 + declOffset);
const birthYear = 1955 + Math.floor(rand() * 45);
const birthMonth = 1 + Math.floor(rand() * 12);
const birthDay = 1 + Math.floor(rand() * 28);
out.push({
ackNo: `${branch.code}/F${formType === 'FORM_60' ? '97' : '98'}/${seq}`,
formType,
name: `${first} ${last}`,
fatherName: `${pick(rand, FIRST_NAMES)} ${last}`,
dateOfBirth: `${birthYear}-${`${birthMonth}`.padStart(2, '0')}-${`${birthDay}`.padStart(2, '0')}`,
address: `${pick(rand, VILLAGES)}, Tal. ${pick(rand, VILLAGES)}, Dist. Thane`,
branchCode: branch.code,
accountNo: `${branch.code}${(4100000 + i * 137).toString().padStart(8, '0')}`,
reasonForNoPan:
formType === 'FORM_61' ? 'Income consists only of agricultural income' : pick(rand, NO_PAN_REASONS),
// Form 98 declarants report agricultural income, which the Act exempts, so
// their declared figures sit below the Form 97 band.
estimatedIncome:
formType === 'FORM_61' ? 90_000 + Math.floor(rand() * 160_000) : 140_000 + Math.floor(rand() * 380_000),
declaredOn: isoDate(declDate),
aadhaarLast4: `${1000 + Math.floor(rand() * 9000)}`
});
}
declarantCache = out;
return out;
}
/**
* Every transaction these customers put through inside the ledger period.
*
* A declaration is taken per transaction category, so an active customer shows
* several rows; a customer who only opened an account shows one.
*/
export function formDeclarantTxns(): FormDeclarantTxn[] {
if (txnCache) {
return txnCache;
}
const rand = lcg(19980731);
const start = new Date(`${GL_PERIOD.fyStart}T00:00:00Z`);
const end = new Date(`${GL_PERIOD.fyEnd}T00:00:00Z`);
const span = Math.round((end.getTime() - start.getTime()) / 86_400_000);
const out: FormDeclarantTxn[] = [];
let voucher = 1;
for (const d of formDeclarants()) {
// Weighted low — most declarants are occasional users and a handful bank
// every week. Squaring the draw gives that long tail rather than a flat
// spread, while still leaving enough volume for a day-wise filter to show.
const count = 1 + Math.floor(rand() * rand() * 22);
for (let n = 0; n < count; n++) {
const day = new Date(start.getTime());
day.setUTCDate(day.getUTCDate() + Math.floor(rand() * span));
if (day.getUTCDay() === 0) {
day.setUTCDate(day.getUTCDate() + 1); // Sunday — no counter business.
}
const kind = pick(rand, KIND_POOL);
const drCr: 'DR' | 'CR' = kind.drCr === 'EITHER' ? (rand() < 0.5 ? 'DR' : 'CR') : kind.drCr;
// The channel follows the category: cash categories go over the counter,
// clearing goes through the house, and the rest split across the
// electronic rails a co-operative bank offers.
let mode: TxnMode;
if (kind.cashOnly) {
mode = kind.nature.startsWith('ATM') ? 'ATM' : 'CASH';
} else if (kind.nonCash) {
mode =
kind.group === 'Clearing'
? 'CLEARING'
: kind.nature.startsWith('RTGS')
? 'RTGS'
: kind.nature.startsWith('NEFT')
? 'NEFT'
: kind.group === 'Digital'
? 'UPI'
: 'TRANSFER';
} else {
// Categories that can go either way — a term deposit or an EMI is paid
// in cash as often as it is transferred at this kind of bank.
mode = rand() < 0.55 ? 'CASH' : 'TRANSFER';
}
// Inside the category's own band, with a thin large-value tail on the
// Rule 114B rows so the report has CTR-sized entries to flag.
let amount = kind.min + Math.floor(rand() * (kind.max - kind.min));
if (kind.rule114B && kind.cashOnly && rand() < 0.09) {
amount = FORM_THRESHOLD.CTR + Math.floor(rand() * 1_400_000);
}
amount = Math.round(amount / 100) * 100 || 100;
const cashWord = mode === 'CASH' || mode === 'ATM';
out.push({
txnId: `TXN${`${out.length + 1}`.padStart(5, '0')}`,
ackNo: d.ackNo,
date: isoDate(day),
branchCode: d.branchCode,
accountNo: d.accountNo,
mode,
drCr,
amount,
nature: kind.nature,
group: kind.group,
rule114B: kind.rule114B,
particulars:
drCr === 'DR'
? `${cashWord ? 'Cash paid to' : 'Debit to account of'} ${d.name} — ${kind.nature.toLowerCase()}`
: `${cashWord ? 'Cash received from' : 'Credit to account of'} ${d.name} — ${kind.nature.toLowerCase()}`,
voucherNo: `${d.branchCode}/V/${`${voucher++}`.padStart(5, '0')}`
});
}
}
out.sort((a, b) => (a.date === b.date ? a.txnId.localeCompare(b.txnId) : a.date.localeCompare(b.date)));
txnCache = out;
return out;
}
export function declarantByAck(ackNo: string): FormDeclarant | undefined {
return formDeclarants().find((d) => d.ackNo === ackNo);
}
@@ -0,0 +1,370 @@
<!--
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-root">
<!-- HEADER -->
<div class="fd-head">
<div class="fd-head-left">
<fa-icon icon="file-signature" class="fd-head-ic"></fa-icon>
<div>
<h2 class="fd-head-t">Form 97 / 98 Transaction Report</h2>
<div class="fd-head-s">
Every transaction put through on a declaration under Rule 114B, for customers who have no PAN. Form 97 is the
general declaration; Form 98 is for a person whose only income is agricultural.
</div>
</div>
</div>
<button class="fd-btn fd-btn--primary" [disabled]="generating" (click)="exportExcel()">
<fa-icon icon="file-excel"></fa-icon> {{ generating ? 'Generating…' : 'Download Excel' }}
</button>
</div>
<!-- FILTERS -->
<div class="fd-filters">
<div class="fd-filter-row">
<div class="fd-modes">
@for (m of periodModes; track m.key) {
<button class="fd-chip" [class.active]="periodMode === m.key" (click)="periodMode = m.key">
{{ m.label }}
</button>
}
</div>
@switch (periodMode) {
@case ('day') {
<label class="fd-field">
<span>Date</span>
<input type="date" [(ngModel)]="filterDay" [min]="period.fyStart" [max]="period.fyEnd" />
</label>
}
@case ('week') {
<label class="fd-field">
<span>Any date in week</span>
<input type="date" [(ngModel)]="filterWeek" [min]="period.fyStart" [max]="period.fyEnd" />
</label>
<span class="fd-resolved">Week: {{ rangeLabel }}</span>
}
@case ('month') {
<label class="fd-field">
<span>Month</span>
<input type="month" [(ngModel)]="filterMonth" min="2026-04" max="2026-07" />
</label>
}
@default {
<label class="fd-field">
<span>From</span>
<input type="date" [(ngModel)]="filterFrom" [min]="period.fyStart" [max]="period.fyEnd" />
</label>
<label class="fd-field">
<span>To</span>
<input type="date" [(ngModel)]="filterTo" [min]="period.fyStart" [max]="period.fyEnd" />
</label>
}
}
</div>
<div class="fd-filter-row">
<label class="fd-field">
<span>Branch</span>
<select [(ngModel)]="branchCode" [disabled]="!canSelectBranch">
@if (canSelectBranch) {
<option value="">All Branches</option>
}
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.code }} — {{ b.name }}</option>
}
</select>
</label>
<label class="fd-field">
<span>Declaration</span>
<select [(ngModel)]="formType">
<option value="">Both</option>
<option value="FORM_60">Form 97 — no PAN</option>
<option value="FORM_61">Form 98 — agricultural income</option>
</select>
</label>
<label class="fd-field">
<span>Transaction type</span>
<select [(ngModel)]="group">
<option value="">All types</option>
@for (g of txnGroups; track g) {
<option [value]="g">{{ g }}</option>
}
</select>
</label>
<label class="fd-field fd-field--grow">
<span>Search</span>
<input type="text" [(ngModel)]="search" placeholder="Name, account number or acknowledgement no…" />
</label>
<label class="fd-check">
<input type="checkbox" [(ngModel)]="flaggedOnly" />
Only CTR reportable
</label>
<button class="fd-btn" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
</div>
@if (!canSelectBranch) {
<div class="fd-scope-note">
<fa-icon icon="lock"></fa-icon>
Showing {{ branchLabel }} only. Head Office logins can select any branch.
</div>
}
</div>
<!-- SUMMARY -->
<div class="fd-kpis">
<div class="fd-kpi">
<div class="fd-kpi-n">{{ summary.persons }}</div>
<div class="fd-kpi-l">Declarants</div>
<div class="fd-kpi-sub">{{ summary.form60Persons }} on Form 97 · {{ summary.form61Persons }} on Form 98</div>
</div>
<div class="fd-kpi fd-kpi--blue">
<div class="fd-kpi-n">{{ summary.txnCount }}</div>
<div class="fd-kpi-l">Transactions — all types</div>
<div class="fd-kpi-sub">{{ summary.declarationRequired }} in a Rule 114B category</div>
</div>
<div class="fd-kpi fd-kpi--amber">
<div class="fd-kpi-n">{{ summary.cashTotal | number: '1.0-0' }}</div>
<div class="fd-kpi-l">Cash turnover (₹)</div>
<div class="fd-kpi-sub">{{ summary.cashCount }} cash transactions</div>
</div>
<div class="fd-kpi" [class.fd-kpi--red]="summary.ctrReportable > 0">
<div class="fd-kpi-n">{{ summary.ctrReportable }}</div>
<div class="fd-kpi-l">CTR reportable</div>
<div class="fd-kpi-sub">cash at or above ₹10,00,000</div>
</div>
</div>
<!-- CATEGORY MIX — what these customers actually do, beyond the declared rows -->
@if (summary.byGroup.length) {
<div class="fd-mix">
<span class="fd-mix-t">Transaction mix</span>
@for (g of summary.byGroup; track g.group) {
<button
class="fd-mix-item"
[class.active]="group === g.group"
(click)="group = group === g.group ? '' : g.group"
>
{{ g.group }} <strong>{{ g.count }}</strong>
</button>
}
</div>
}
<!-- VIEW SWITCH -->
<div class="fd-views">
<button class="fd-chip" [class.active]="view === 'person'" (click)="view = 'person'">
<fa-icon icon="users"></fa-icon> Person wise
</button>
<button class="fd-chip" [class.active]="view === 'transaction'" (click)="view = 'transaction'">
<fa-icon icon="list"></fa-icon> Transaction wise
</button>
<span class="fd-spacer"></span>
<span class="fd-count">
{{ view === 'person' ? personRows.length + ' declarants' : txnRows.length + ' transactions' }}
</span>
</div>
<!-- PERSON WISE -->
@if (view === 'person') {
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th></th>
<th>Acknowledgement</th>
<th>Form</th>
<th>Name of declarant</th>
<th>Account No</th>
<th>Branch</th>
<th class="num">Txns</th>
<th class="num">Rule 114B</th>
<th class="num">Cash turnover</th>
<th class="num">Credit</th>
<th class="num">Debit</th>
<th class="num">Declared income</th>
<th class="num">Cash ÷ income</th>
</tr>
</thead>
<tbody>
@for (p of personRows; track p.declarant.ackNo) {
<tr class="fd-person" [class.open]="expanded === p.declarant.ackNo" (click)="toggle(p.declarant.ackNo)">
<td>
<fa-icon [icon]="expanded === p.declarant.ackNo ? 'caret-down' : 'caret-right'"></fa-icon>
</td>
<td class="mono">{{ p.declarant.ackNo }}</td>
<td>
<span class="fd-form" [class.fd-form--61]="p.declarant.formType === 'FORM_61'">
{{ formLabel[p.declarant.formType] }}
</span>
</td>
<td>
<strong>{{ p.declarant.name }}</strong>
@if (p.ctrCount > 0) {
<span class="fd-tag fd-tag--ctr">{{ p.ctrCount }} CTR</span>
}
</td>
<td class="mono">{{ p.declarant.accountNo }}</td>
<td>{{ p.declarant.branchCode }}</td>
<td class="num">{{ p.txnCount }}</td>
<td class="num">{{ p.declaredCount }}</td>
<td class="num strong">{{ p.cashTotal | number: '1.0-0' }}</td>
<td class="num">{{ p.totalCredit | number: '1.0-0' }}</td>
<td class="num">{{ p.totalDebit | number: '1.0-0' }}</td>
<td class="num">{{ p.declarant.estimatedIncome | number: '1.0-0' }}</td>
<td class="num">
<span [class]="ratioClass(p.incomeRatio)">{{ p.incomeRatio | number: '1.1-1' }}×</span>
</td>
</tr>
@if (expanded === p.declarant.ackNo) {
<tr class="fd-detail">
<td colspan="13">
<div class="fd-decl">
<div>
<label>Father's name</label><span>{{ p.declarant.fatherName }}</span>
</div>
<div>
<label>Date of birth</label><span>{{ p.declarant.dateOfBirth }}</span>
</div>
<div>
<label>Aadhaar</label><span class="mono">XXXX XXXX {{ p.declarant.aadhaarLast4 }}</span>
</div>
<div>
<label>Declared on</label><span>{{ p.declarant.declaredOn }}</span>
</div>
<div class="wide">
<label>Address</label><span>{{ p.declarant.address }}</span>
</div>
<div class="wide">
<label>Reason for no PAN</label><span>{{ p.declarant.reasonForNoPan }}</span>
</div>
</div>
<table class="fd-subtable">
<thead>
<tr>
<th>Date</th>
<th>Txn ID</th>
<th>Mode</th>
<th>Dr/Cr</th>
<th class="num">Amount</th>
<th>Nature of transaction (Rule 114B)</th>
<th>Voucher</th>
<th>Flag</th>
</tr>
</thead>
<tbody>
@for (t of p.txns; track t.txnId) {
<tr>
<td class="mono">{{ t.date }}</td>
<td class="mono">{{ t.txnId }}</td>
<td>
<span class="fd-mode">{{ t.mode }}</span>
</td>
<td [class.cr]="t.drCr === 'CR'" [class.dr]="t.drCr === 'DR'">{{ t.drCr }}</td>
<td class="num strong">{{ t.amount | number: '1.0-0' }}</td>
<td>{{ t.nature }}</td>
<td class="mono">{{ t.voucherNo }}</td>
<td>
@if (t.ctrReportable) {
<span class="fd-tag fd-tag--ctr">CTR</span>
} @else if (t.declarationRequired) {
<span class="fd-tag">Rule 114B</span>
}
</td>
</tr>
}
</tbody>
</table>
</td>
</tr>
}
} @empty {
<tr>
<td colspan="13" class="fd-empty">
No Form 97 or Form 98 transactions for {{ rangeLabel }} at {{ branchLabel }}.
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- TRANSACTION WISE -->
@if (view === 'transaction') {
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th>Date</th>
<th>Txn ID</th>
<th>Acknowledgement</th>
<th>Form</th>
<th>Name of declarant</th>
<th>Account No</th>
<th>Branch</th>
<th>Category</th>
<th>Mode</th>
<th>Dr/Cr</th>
<th class="num">Amount</th>
<th>Nature of transaction</th>
<th>Voucher</th>
<th>Flag</th>
</tr>
</thead>
<tbody>
@for (t of txnRows; track t.txnId) {
<tr [class.fd-row--ctr]="t.ctrReportable">
<td class="mono">{{ t.date }}</td>
<td class="mono">{{ t.txnId }}</td>
<td class="mono">{{ t.ackNo }}</td>
<td>
<span class="fd-form" [class.fd-form--61]="t.formType === 'FORM_61'">{{ formLabel[t.formType] }}</span>
</td>
<td>{{ t.declarantName }}</td>
<td class="mono">{{ t.accountNo }}</td>
<td>{{ t.branchCode }}</td>
<td>
<span class="fd-group">{{ t.group }}</span>
</td>
<td>
<span class="fd-mode">{{ t.mode }}</span>
</td>
<td [class.cr]="t.drCr === 'CR'" [class.dr]="t.drCr === 'DR'">{{ t.drCr }}</td>
<td class="num strong">{{ t.amount | number: '1.0-0' }}</td>
<td>{{ t.nature }}</td>
<td class="mono">{{ t.voucherNo }}</td>
<td>
@if (t.ctrReportable) {
<span class="fd-tag fd-tag--ctr">CTR</span>
} @else if (t.declarationRequired) {
<span class="fd-tag">Rule 114B</span>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="14" class="fd-empty">
No Form 97 or Form 98 transactions for {{ rangeLabel }} at {{ branchLabel }}.
</td>
</tr>
}
</tbody>
</table>
</div>
}
<div class="fd-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
</div>
@@ -0,0 +1,608 @@
/**
* 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 {
--f-navy: #1a237e;
--f-blue: #1d4ed8;
--f-blue-l: #eff6ff;
--f-green: #15803d;
--f-green-l: #dcfce7;
--f-amber: #b45309;
--f-amber-l: #fef3c7;
--f-red: #c0392b;
--f-red-l: #fdecea;
--f-g50: #f8fafc;
--f-g100: #f1f5f9;
--f-g200: #e2e8f0;
--f-g300: #cbd5e1;
--f-g500: #64748b;
--f-g700: #334155;
--f-g900: #0f172a;
display: block;
font-family: 'DM Sans', system-ui, sans-serif;
background: var(--f-g50);
color: var(--f-g900);
font-size: 14px;
line-height: 1.5;
}
.fd-root {
padding: 18px 22px;
max-width: 1700px;
margin: 0 auto;
}
/* HEADER */
.fd-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.fd-head-left {
display: flex;
align-items: flex-start;
gap: 14px;
}
.fd-head-ic {
font-size: 26px;
color: var(--f-navy);
margin-top: 3px;
}
.fd-head-t {
margin: 0;
font-size: 18px;
font-weight: 700;
}
.fd-head-s {
font-size: 12px;
color: var(--f-g500);
max-width: 820px;
}
/* BUTTONS */
.fd-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 15px;
border: 1px solid var(--f-g300);
background: #fff;
border-radius: 8px;
font-size: 12.5px;
font-weight: 600;
color: var(--f-g700);
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
&:hover:not(:disabled) {
background: var(--f-g50);
border-color: var(--f-g500);
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
&--primary {
background: var(--f-green);
border-color: var(--f-green);
color: #fff;
&:hover:not(:disabled) {
background: #12692f;
border-color: #12692f;
color: #fff;
}
}
}
/* FILTERS */
.fd-filters {
background: #fff;
border: 1px solid var(--f-g200);
border-radius: 11px;
padding: 12px 16px;
margin-bottom: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.fd-filter-row {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
}
.fd-modes {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.fd-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: 1px solid var(--f-g300);
background: #fff;
border-radius: 999px;
font-size: 12.5px;
font-weight: 600;
color: var(--f-g700);
cursor: pointer;
font-family: inherit;
&:hover {
border-color: var(--f-navy);
}
&.active {
background: var(--f-navy);
border-color: var(--f-navy);
color: #fff;
}
}
.fd-field {
display: flex;
flex-direction: column;
gap: 3px;
span {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--f-g500);
}
input,
select {
padding: 7px 10px;
border: 1px solid var(--f-g300);
border-radius: 7px;
font-size: 13px;
font-family: inherit;
background: #fff;
color: var(--f-g900);
&:focus {
outline: none;
border-color: var(--f-navy);
}
&:disabled {
background: var(--f-g100);
color: var(--f-g500);
}
}
&--grow {
flex: 1;
min-width: 240px;
input {
width: 100%;
}
}
}
.fd-resolved {
font-size: 12px;
color: var(--f-g500);
padding-bottom: 8px;
}
.fd-check {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
font-weight: 600;
color: var(--f-g700);
padding-bottom: 8px;
cursor: pointer;
}
.fd-scope-note {
display: flex;
align-items: center;
gap: 7px;
font-size: 12px;
color: var(--f-amber);
background: var(--f-amber-l);
border: 1px solid #fcd34d;
border-radius: 7px;
padding: 6px 11px;
}
/* KPIs */
.fd-kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 14px;
}
.fd-kpi {
background: #fff;
border: 1px solid var(--f-g200);
border-left: 4px solid var(--f-navy);
border-radius: 11px;
padding: 12px 16px;
&--blue {
border-left-color: var(--f-blue);
}
&--amber {
border-left-color: var(--f-amber);
}
&--red {
border-left-color: var(--f-red);
}
}
.fd-kpi-n {
font-size: 22px;
font-weight: 700;
font-family: 'DM Mono', monospace;
}
.fd-kpi-l {
font-size: 12px;
font-weight: 600;
color: var(--f-g700);
}
.fd-kpi-sub {
font-size: 11px;
color: var(--f-g500);
}
/* VIEW SWITCH */
.fd-views {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.fd-spacer {
flex: 1;
}
.fd-count {
font-size: 12px;
color: var(--f-g500);
font-weight: 600;
}
/* TABLE */
.fd-table-wrap {
background: #fff;
border: 1px solid var(--f-g200);
border-radius: 11px;
overflow-x: auto;
}
.fd-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
th {
text-align: left;
padding: 9px 10px;
background: var(--f-g100);
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--f-g700);
white-space: nowrap;
border-bottom: 1px solid var(--f-g200);
}
td {
padding: 8px 10px;
border-bottom: 1px solid var(--f-g100);
vertical-align: top;
}
.num {
text-align: right;
font-family: 'DM Mono', monospace;
}
.strong {
font-weight: 700;
}
tbody tr:hover:not(.fd-detail) {
background: var(--f-g50);
}
}
.fd-person {
cursor: pointer;
&.open {
background: var(--f-blue-l);
}
}
.fd-row--ctr {
background: var(--f-red-l);
}
.fd-detail {
background: #fcfcfe;
td {
padding: 12px 16px 16px;
}
}
.fd-decl {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px 18px;
margin-bottom: 12px;
padding: 10px 14px;
background: #fff;
border: 1px solid var(--f-g200);
border-radius: 8px;
> div {
display: flex;
flex-direction: column;
}
.wide {
grid-column: span 2;
}
label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--f-g500);
}
span {
font-size: 12.5px;
}
}
.fd-subtable {
width: 100%;
border-collapse: collapse;
font-size: 12px;
background: #fff;
border: 1px solid var(--f-g200);
border-radius: 8px;
overflow: hidden;
th {
text-align: left;
padding: 7px 10px;
background: var(--f-g50);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--f-g500);
white-space: nowrap;
}
td {
padding: 6px 10px;
border-top: 1px solid var(--f-g100);
}
.num {
text-align: right;
font-family: 'DM Mono', monospace;
}
.strong {
font-weight: 700;
}
}
/* Badges */
.fd-form {
display: inline-block;
padding: 1px 8px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
background: var(--f-blue-l);
color: var(--f-blue);
white-space: nowrap;
&--61 {
background: var(--f-green-l);
color: var(--f-green);
}
}
.fd-mode {
display: inline-block;
padding: 1px 7px;
border-radius: 5px;
font-size: 10.5px;
font-weight: 700;
background: var(--f-g100);
color: var(--f-g700);
}
.fd-group {
display: inline-block;
padding: 1px 8px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 600;
background: #fff;
border: 1px solid var(--f-g300);
color: var(--f-g700);
white-space: nowrap;
}
/* Category mix — doubles as a one-click filter. */
.fd-mix {
display: flex;
align-items: center;
gap: 7px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.fd-mix-t {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--f-g500);
margin-right: 2px;
}
.fd-mix-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 11px;
border: 1px solid var(--f-g200);
background: #fff;
border-radius: 7px;
font-size: 11.5px;
font-weight: 600;
color: var(--f-g700);
cursor: pointer;
font-family: inherit;
strong {
font-family: 'DM Mono', monospace;
color: var(--f-navy);
}
&:hover {
border-color: var(--f-navy);
}
&.active {
background: var(--f-navy);
border-color: var(--f-navy);
color: #fff;
strong {
color: #fff;
}
}
}
.fd-tag {
display: inline-block;
margin-left: 6px;
padding: 1px 7px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
background: var(--f-amber-l);
color: var(--f-amber);
white-space: nowrap;
&--ctr {
background: var(--f-red-l);
color: var(--f-red);
}
}
.fd-ratio {
font-weight: 600;
&--mid {
color: var(--f-amber);
}
&--high {
color: var(--f-red);
font-weight: 700;
}
}
.cr {
color: var(--f-green);
font-weight: 700;
}
.dr {
color: var(--f-red);
font-weight: 700;
}
.mono {
font-family: 'DM Mono', monospace;
font-size: 11.5px;
}
.fd-empty {
padding: 44px;
text-align: center;
color: var(--f-g500);
font-size: 13px;
}
/* TOAST */
.fd-toast {
position: fixed;
bottom: 32px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--f-navy);
color: #fff;
padding: 12px 22px;
border-radius: 10px;
font-size: 13px;
box-shadow: 0 6px 20px rgb(0 0 0 / 25%);
opacity: 0;
pointer-events: none;
transition: all 0.25s;
z-index: 1500;
&.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
@media (width <= 1200px) {
.fd-kpis {
grid-template-columns: 1fr 1fr;
}
.fd-decl {
grid-template-columns: 1fr 1fr;
}
}
@@ -0,0 +1,474 @@
/**
* 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 { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import * as ExcelJS from 'exceljs';
import { BranchScopeService } from '../core/branch-scope.service';
import { GL_BRANCHES, GL_PERIOD, glBranchName } from '../gl-reports/gl-reports-data';
import { FORM_LABEL, FORM_THRESHOLD, FormType, TXN_GROUPS } from './form-declaration-data';
import {
DateRange,
FormPersonRow,
FormReportFilter,
FormReportSummary,
FormTxnRow,
formPersonRows,
formReportSummary,
formTxnRows,
monthRange,
weekRange
} from './form-declaration.compute';
type PeriodMode = 'day' | 'week' | 'month' | 'range';
type ViewMode = 'person' | 'transaction';
@Component({
selector: 'mifosx-form-declaration-report',
templateUrl: './form-declaration-report.component.html',
styleUrls: ['./form-declaration-report.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
FormsModule
]
})
export class FormDeclarationReportComponent implements OnInit {
private scope = inject(BranchScopeService);
readonly period = GL_PERIOD;
readonly branches = GL_BRANCHES;
readonly formLabel = FORM_LABEL;
readonly threshold = FORM_THRESHOLD;
readonly txnGroups = TXN_GROUPS;
readonly periodModes: { key: PeriodMode; label: string }[] = [
{ key: 'day', label: 'Day wise' },
{ key: 'week', label: 'Week wise' },
{ key: 'month', label: 'Month wise' },
{ key: 'range', label: 'Between dates' }
];
// ── Filter state ───────────────────────────────────────────────────────────
periodMode: PeriodMode = 'month';
filterDay = '2026-07-31';
/** Any date inside the wanted week; resolved to Mon–Sat. */
filterWeek = '2026-07-27';
filterMonth = '2026-07';
filterFrom = GL_PERIOD.fyStart;
filterTo = GL_PERIOD.fyEnd;
branchCode = '';
formType: FormType | '' = '';
group = '';
search = '';
flaggedOnly = false;
view: ViewMode = 'person';
/** Acknowledgement number of the person row currently expanded. */
expanded: string | null = null;
generating = false;
toastMsg = '';
toastVisible = false;
private toastTimer: any;
ngOnInit(): void {
// A branch login opens on its own book and cannot move off it.
this.branchCode = this.scope.defaultBranchCode;
}
// ── Filter resolution ──────────────────────────────────────────────────────
get range(): DateRange {
if (this.periodMode === 'day') {
return { from: this.filterDay, to: this.filterDay };
}
if (this.periodMode === 'week') {
return weekRange(this.filterWeek);
}
if (this.periodMode === 'month') {
return monthRange(this.filterMonth);
}
return { from: this.filterFrom, to: this.filterTo };
}
get rangeLabel(): string {
const r = this.range;
return r.from === r.to ? r.from : `${r.from} to ${r.to}`;
}
get canSelectBranch(): boolean {
return this.scope.canSelectBranch;
}
get branchLabel(): string {
if (!this.canSelectBranch) {
return this.scope.officeName;
}
return this.branchCode ? glBranchName(this.branchCode) : 'All Branches (Consolidated)';
}
/**
* Applied at the data boundary rather than only in the UI, so a branch login
* cannot widen its own scope by tampering with component state.
*/
private get effectiveBranch(): string {
return this.scope.enforce(this.branchCode);
}
private get filter(): FormReportFilter {
return {
range: this.range,
branchCode: this.effectiveBranch,
formType: this.formType,
group: this.group,
search: this.search,
flaggedOnly: this.flaggedOnly
};
}
resetFilters(): void {
this.periodMode = 'month';
this.filterDay = '2026-07-31';
this.filterWeek = '2026-07-27';
this.filterMonth = '2026-07';
this.filterFrom = GL_PERIOD.fyStart;
this.filterTo = GL_PERIOD.fyEnd;
this.formType = '';
this.group = '';
this.search = '';
this.flaggedOnly = false;
this.expanded = null;
// Reset returns a branch login to its own branch, never to "all".
this.branchCode = this.scope.defaultBranchCode;
}
// ── Data ───────────────────────────────────────────────────────────────────
get summary(): FormReportSummary {
return formReportSummary(this.filter);
}
get personRows(): FormPersonRow[] {
return formPersonRows(this.filter);
}
get txnRows(): FormTxnRow[] {
return formTxnRows(this.filter);
}
toggle(ackNo: string): void {
this.expanded = this.expanded === ackNo ? null : ackNo;
}
/** Cash turnover far above declared income is the point of the report. */
ratioClass(ratio: number): string {
if (ratio >= 5) {
return 'fd-ratio fd-ratio--high';
}
if (ratio >= 2) {
return 'fd-ratio fd-ratio--mid';
}
return 'fd-ratio';
}
// ── Excel ──────────────────────────────────────────────────────────────────
async exportExcel(): Promise<void> {
this.generating = true;
try {
const wb = new ExcelJS.Workbook();
wb.creator = 'CBSNexora';
wb.created = new Date();
this.writeSummarySheet(wb);
this.writePersonSheet(wb);
this.writeTransactionSheet(wb);
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `Form-97-98-Transaction-Report-${this.range.from}-to-${this.range.to}.xlsx`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.toast(`Exported ${this.summary.txnCount} transactions for ${this.rangeLabel}`);
} catch {
this.toast('Could not generate the workbook — please retry');
} finally {
this.generating = false;
}
}
/** Title band + filter caption, repeated on every sheet so pages stand alone. */
private banner(sheet: ExcelJS.Worksheet, title: string, cols: number): void {
const t = sheet.addRow([title]);
sheet.mergeCells(t.number, 1, t.number, cols);
t.font = { bold: true, size: 14, color: { argb: 'FFFFFFFF' } };
t.alignment = { horizontal: 'center', vertical: 'middle' };
t.height = 24;
t.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1A237E' } };
const formCaption = this.formType ? FORM_LABEL[this.formType] : 'Form 97 and Form 98';
const meta = sheet.addRow([
`Period: ${this.rangeLabel} · Branch: ${this.branchLabel} · Declaration: ${formCaption}` +
` · Category: ${this.group || 'All transaction types'}`
]);
sheet.mergeCells(meta.number, 1, meta.number, cols);
meta.font = { italic: true, size: 10 };
sheet.addRow([]);
}
private headerRow(sheet: ExcelJS.Worksheet, headers: string[]): void {
const head = sheet.addRow(headers);
head.font = { bold: true };
head.alignment = { wrapText: true, horizontal: 'center' };
head.eachCell((c) => {
c.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } };
c.border = { bottom: { style: 'thin' } };
});
}
private writeSummarySheet(wb: ExcelJS.Workbook): void {
const s = this.summary;
const sheet = wb.addWorksheet('Summary');
sheet.columns = [
{ width: 52 },
{ width: 24 }];
this.banner(sheet, 'Form 97 / 98 Transaction Report — Summary', 2);
this.headerRow(sheet, [
'Particulars',
'Value'
]);
const money = new Set([
'Total cash turnover',
'Total credits',
'Total debits'
]);
const lines: [string, string | number][] = [
[
'Declarants transacting in the period',
s.persons
],
[
' of which Form 97 (no PAN)',
s.form60Persons
],
[
' of which Form 98 (agricultural income only)',
s.form61Persons
],
[
'Total transactions — all types',
s.txnCount
],
[
' of which cash (counter and ATM)',
s.cashCount
],
[
' of which in a Rule 114B category',
s.declarationRequired
],
[
'Total cash turnover',
s.cashTotal
],
[
'Total credits',
s.totalCredit
],
[
'Total debits',
s.totalDebit
],
[
`Cash transactions at or above ₹${FORM_THRESHOLD.CTR.toLocaleString('en-IN')} (CTR reportable)`,
s.ctrReportable
]
];
for (const [
label,
value
] of lines) {
const row = sheet.addRow([
label,
value
]);
if (money.has(label)) {
row.getCell(2).numFmt = '#,##0.00';
}
}
// Category mix — what the customer group actually does, beyond the
// declared transactions.
sheet.addRow([]);
this.headerRow(sheet, [
'Transaction category',
'Count'
]);
for (const g of s.byGroup) {
sheet.addRow([
g.group,
g.count
]);
}
sheet.views = [{ state: 'frozen', ySplit: 4 }];
}
private writePersonSheet(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('Person wise');
const widths = [
18,
12,
26,
24,
18,
14,
10,
10,
10,
16,
16,
16,
14,
10,
34
];
sheet.columns = widths.map((w) => ({ width: w }));
this.banner(sheet, 'Form 97 / 98 Transaction Report — Person wise', widths.length);
this.headerRow(sheet, [
'Acknowledgement',
'Form',
'Name of declarant',
"Father's name",
'Account No',
'Branch',
'Total txns',
'Cash txns',
'Rule 114B txns',
'Cash turnover',
'Total credit',
'Total debit',
'Declared income',
'CTR rows',
'Categories transacted'
]);
for (const p of this.personRows) {
const row = sheet.addRow([
p.declarant.ackNo,
FORM_LABEL[p.declarant.formType],
p.declarant.name,
p.declarant.fatherName,
p.declarant.accountNo,
glBranchName(p.declarant.branchCode),
p.txnCount,
p.cashCount,
p.declaredCount,
p.cashTotal,
p.totalCredit,
p.totalDebit,
p.declarant.estimatedIncome,
p.ctrCount,
p.groups.join(', ')
]);
for (const c of [
10,
11,
12,
13
]) {
row.getCell(c).numFmt = '#,##0.00';
}
}
sheet.views = [{ state: 'frozen', ySplit: 4 }];
}
private writeTransactionSheet(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('Transactions');
const widths = [
12,
12,
18,
12,
26,
18,
14,
12,
10,
6,
16,
46,
16,
12
];
sheet.columns = widths.map((w) => ({ width: w }));
this.banner(sheet, 'Form 97 / 98 Transaction Report — Transaction wise', widths.length);
this.headerRow(sheet, [
'Date',
'Txn ID',
'Acknowledgement',
'Form',
'Name of declarant',
'Account No',
'Branch',
'Category',
'Mode',
'Dr/Cr',
'Amount',
'Nature of transaction',
'Voucher No',
'Flag'
]);
for (const t of this.txnRows) {
const row = sheet.addRow([
t.date,
t.txnId,
t.ackNo,
FORM_LABEL[t.formType],
t.declarantName,
t.accountNo,
glBranchName(t.branchCode),
t.group,
t.mode,
t.drCr,
t.amount,
t.nature,
t.voucherNo,
t.ctrReportable ? 'CTR' : t.declarationRequired ? 'Rule 114B' : ''
]);
row.getCell(11).numFmt = '#,##0.00';
if (t.ctrReportable) {
row.getCell(14).font = { bold: true, color: { argb: 'FFC0392B' } };
}
}
sheet.views = [{ state: 'frozen', ySplit: 4 }];
}
private toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
if (this.toastTimer) {
clearTimeout(this.toastTimer);
}
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
}
}
@@ -0,0 +1,296 @@
/**
* 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 { GL_PERIOD } from '../gl-reports/gl-reports-data';
import {
FORM_THRESHOLD,
FormDeclarant,
FormDeclarantTxn,
FormType,
formDeclarants,
formDeclarantTxns
} from './form-declaration-data';
export interface DateRange {
from: string;
to: string;
}
export interface FormReportFilter {
range: DateRange;
/** '' means every branch. */
branchCode: string;
/** '' means both declaration types. */
formType: FormType | '';
/** '' means every transaction category (Cash, Clearing, Loan, …). */
group: string;
/** Free text over name, account number and acknowledgement number. */
search: string;
/** Show only rows a compliance officer would need to look at. */
flaggedOnly: boolean;
}
/** One transaction, joined to the declarant who filed for it. */
export interface FormTxnRow extends FormDeclarantTxn {
declarantName: string;
formType: FormType;
fatherName: string;
declaredOn: string;
reasonForNoPan: string;
estimatedIncome: number;
/** Counter or ATM cash, as opposed to a book or electronic entry. */
isCash: boolean;
/**
* The transaction is one of the categories in Rule 114B, so PAN — and in its
* absence the Form 97/98 declaration — was mandatory for it.
*/
declarationRequired: boolean;
/** Cash at or above the CTR limit — also lands in the monthly CTR. */
ctrReportable: boolean;
}
/** A declarant with their totals for the period. */
export interface FormPersonRow {
declarant: FormDeclarant;
txns: FormTxnRow[];
txnCount: number;
cashCount: number;
totalCredit: number;
totalDebit: number;
cashTotal: number;
/** Largest single transaction, used to sort the riskiest to the top. */
maxAmount: number;
/** How many of the period's transactions needed the declaration. */
declaredCount: number;
ctrCount: number;
/** Distinct categories the customer transacted in during the period. */
groups: string[];
/**
* Aggregate cash for the period against the income the customer declared.
* A ratio well above 1 is the classic "turnover inconsistent with declared
* income" flag, which is why the declaration is collected in the first place.
*/
incomeRatio: number;
}
export interface FormReportSummary {
persons: number;
form60Persons: number;
form61Persons: number;
txnCount: number;
cashTotal: number;
cashCount: number;
totalCredit: number;
totalDebit: number;
/** Transactions falling in a Rule 114B category. */
declarationRequired: number;
ctrReportable: number;
/** Transaction count by category, largest first — drives the mix panel. */
byGroup: { group: string; count: number; amount: number }[];
}
function clamp(range: DateRange): DateRange {
const from = range.from < GL_PERIOD.fyStart ? GL_PERIOD.fyStart : range.from;
const to = range.to > GL_PERIOD.fyEnd ? GL_PERIOD.fyEnd : range.to;
// A backwards range would silently return nothing; collapse it to a single day.
return from > to ? { from: to, to: to } : { from, to };
}
/** Monday-to-Saturday banking week containing the given date. */
export function weekRange(anchor: string): DateRange {
const d = new Date(`${anchor}T00:00:00Z`);
const dow = d.getUTCDay(); // 0 = Sunday
const monday = new Date(d.getTime());
monday.setUTCDate(d.getUTCDate() - (dow === 0 ? 6 : dow - 1));
const saturday = new Date(monday.getTime());
saturday.setUTCDate(monday.getUTCDate() + 5);
const iso = (x: Date) => x.toISOString().slice(0, 10);
return { from: iso(monday), to: iso(saturday) };
}
export function monthRange(month: string): DateRange {
const [
y,
m
] = month.split('-').map(Number);
const last = new Date(Date.UTC(y, m, 0)).getUTCDate();
return { from: `${month}-01`, to: `${month}-${`${last}`.padStart(2, '0')}` };
}
/**
* Transactions matching the filter, each joined to its declarant.
*
* Memoised on the filter signature: the getters that feed the template are read
* many times per change-detection pass, and re-scanning several hundred rows on
* every read is what made the GL reports stall.
*/
let rowCacheKey = '';
let rowCache: FormTxnRow[] = [];
export function formTxnRows(filter: FormReportFilter): FormTxnRow[] {
const range = clamp(filter.range);
const key = [
range.from,
range.to,
filter.branchCode,
filter.formType,
filter.group,
filter.search.trim().toLowerCase(),
filter.flaggedOnly ? '1' : '0'
].join('|');
if (key === rowCacheKey) {
return rowCache;
}
const byAck = new Map(
formDeclarants().map((d) => [
d.ackNo,
d
])
);
const needle = filter.search.trim().toLowerCase();
const rows: FormTxnRow[] = [];
for (const t of formDeclarantTxns()) {
if (t.date < range.from || t.date > range.to) {
continue;
}
if (filter.branchCode && t.branchCode !== filter.branchCode) {
continue;
}
if (filter.group && t.group !== filter.group) {
continue;
}
const d = byAck.get(t.ackNo);
if (!d) {
continue;
}
if (filter.formType && d.formType !== filter.formType) {
continue;
}
if (
needle &&
!d.name.toLowerCase().includes(needle) &&
!t.accountNo.toLowerCase().includes(needle) &&
!d.ackNo.toLowerCase().includes(needle)
) {
continue;
}
// ATM dispensing is cash in the customer's hands, so it counts towards the
// cash view even though it never crossed the counter.
const isCash = t.mode === 'CASH' || t.mode === 'ATM';
const ctrReportable = isCash && t.amount >= FORM_THRESHOLD.CTR;
if (filter.flaggedOnly && !ctrReportable) {
continue;
}
rows.push({
...t,
declarantName: d.name,
formType: d.formType,
fatherName: d.fatherName,
declaredOn: d.declaredOn,
reasonForNoPan: d.reasonForNoPan,
estimatedIncome: d.estimatedIncome,
isCash,
declarationRequired: t.rule114B,
ctrReportable
});
}
rowCacheKey = key;
rowCache = rows;
return rows;
}
/** The same rows grouped by declarant, heaviest customer first. */
export function formPersonRows(filter: FormReportFilter): FormPersonRow[] {
const byAck = new Map(
formDeclarants().map((d) => [
d.ackNo,
d
])
);
const grouped = new Map<string, FormTxnRow[]>();
for (const r of formTxnRows(filter)) {
const list = grouped.get(r.ackNo);
if (list) {
list.push(r);
} else {
grouped.set(r.ackNo, [r]);
}
}
const out: FormPersonRow[] = [];
for (const [
ackNo,
txns
] of grouped) {
const declarant = byAck.get(ackNo);
if (!declarant) {
continue;
}
const cash = txns.filter((t) => t.isCash);
const cashTotal = cash.reduce((s, t) => s + t.amount, 0);
out.push({
declarant,
txns,
txnCount: txns.length,
cashCount: cash.length,
totalCredit: txns.filter((t) => t.drCr === 'CR').reduce((s, t) => s + t.amount, 0),
totalDebit: txns.filter((t) => t.drCr === 'DR').reduce((s, t) => s + t.amount, 0),
cashTotal,
maxAmount: txns.reduce((m, t) => Math.max(m, t.amount), 0),
declaredCount: txns.filter((t) => t.declarationRequired).length,
ctrCount: txns.filter((t) => t.ctrReportable).length,
groups: [...new Set(txns.map((t) => t.group))].sort(),
incomeRatio: declarant.estimatedIncome > 0 ? cashTotal / declarant.estimatedIncome : 0
});
}
out.sort((a, b) => b.cashTotal - a.cashTotal);
return out;
}
export function formReportSummary(filter: FormReportFilter): FormReportSummary {
const rows = formTxnRows(filter);
const acks = new Set(rows.map((r) => r.ackNo));
const form61 = new Set(rows.filter((r) => r.formType === 'FORM_61').map((r) => r.ackNo));
const form60 = new Set(rows.filter((r) => r.formType === 'FORM_60').map((r) => r.ackNo));
const groupMap = new Map<string, { group: string; count: number; amount: number }>();
for (const r of rows) {
const g = groupMap.get(r.group);
if (g) {
g.count++;
g.amount += r.amount;
} else {
groupMap.set(r.group, { group: r.group, count: 1, amount: r.amount });
}
}
const cash = rows.filter((r) => r.isCash);
return {
persons: acks.size,
form60Persons: form60.size,
form61Persons: form61.size,
txnCount: rows.length,
cashTotal: cash.reduce((s, r) => s + r.amount, 0),
cashCount: cash.length,
totalCredit: rows.filter((r) => r.drCr === 'CR').reduce((s, r) => s + r.amount, 0),
totalDebit: rows.filter((r) => r.drCr === 'DR').reduce((s, r) => s + r.amount, 0),
declarationRequired: rows.filter((r) => r.declarationRequired).length,
ctrReportable: rows.filter((r) => r.ctrReportable).length,
byGroup: [...groupMap.values()].sort((a, b) => b.count - a.count)
};
}
+723
View File
@@ -0,0 +1,723 @@
/**
* 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/.
*/
/**
* Data layer for the General Ledger report suite.
*
* Rather than hard-coding seven finished reports, this file holds a chart of
* accounts plus a generated ledger of double-entry postings. Every report is
* then computed from those postings, which is what makes the day / month /
* branch filters genuinely recompute instead of just re-labelling a static table.
*
* The generator is deterministic — a fixed-seed LCG, never Math.random — so the
* figures are identical on every reload and across users. Amounts are in rupees.
*/
export type GlAccountType = 'ASSET' | 'LIABILITY' | 'INCOME' | 'EXPENSE' | 'CONTINGENT';
export interface GlAccount {
code: string;
name: string;
type: GlAccountType;
/** Balance-sheet / P&L grouping used as a sub-heading in the reports. */
group: string;
/** Included in the Misc Expense Ledger report. */
miscExpense?: boolean;
}
export interface GlBranch {
code: string;
name: string;
}
/** One side of a double-entry voucher. */
export interface GlPosting {
/** ISO yyyy-MM-dd — the posting (value) date. */
date: string;
branchCode: string;
accountCode: string;
debit: number;
credit: number;
voucherNo: string;
narration: string;
}
export const GL_BRANCHES: GlBranch[] = [
{ code: 'HO', name: 'Head Office' },
{ code: '0002', name: 'Andheri (E)' },
{ code: '0003', name: 'Bandra (W)' },
{ code: '0004', name: 'Borivali' },
{ code: '0005', name: 'Dadar' },
{ code: '0006', name: 'Thane' }
];
export const GL_ACCOUNTS: GlAccount[] = [
// ── Assets ────────────────────────────────────────────────────────────────
{ code: '110101', name: 'Cash in Hand', type: 'ASSET', group: 'Cash & Bank Balances' },
{ code: '110102', name: 'Balance with RBI', type: 'ASSET', group: 'Cash & Bank Balances' },
{ code: '110103', name: 'Balance with Other Banks — Current', type: 'ASSET', group: 'Cash & Bank Balances' },
{ code: '110104', name: 'Balance with Other Banks — Fixed', type: 'ASSET', group: 'Cash & Bank Balances' },
{ code: '120101', name: 'SLR Investments — Govt Securities', type: 'ASSET', group: 'Investments' },
{ code: '120102', name: 'SLR Investments — State Govt Securities', type: 'ASSET', group: 'Investments' },
{ code: '120201', name: 'Non-SLR Investments — Bonds', type: 'ASSET', group: 'Investments' },
{ code: '120202', name: 'Non-SLR Investments — Mutual Funds', type: 'ASSET', group: 'Investments' },
{ code: '130101', name: 'Cash Credit & Overdrafts', type: 'ASSET', group: 'Loans & Advances' },
{ code: '130102', name: 'Term Loans — Short Term', type: 'ASSET', group: 'Loans & Advances' },
{ code: '130103', name: 'Term Loans — Medium Term', type: 'ASSET', group: 'Loans & Advances' },
{ code: '130104', name: 'Term Loans — Long Term', type: 'ASSET', group: 'Loans & Advances' },
{ code: '130105', name: 'Housing Loans', type: 'ASSET', group: 'Loans & Advances' },
{ code: '130106', name: 'Gold Loans', type: 'ASSET', group: 'Loans & Advances' },
{ code: '140101', name: 'Interest Receivable', type: 'ASSET', group: 'Other Assets' },
{ code: '140102', name: 'Premises (net of depreciation)', type: 'ASSET', group: 'Fixed Assets' },
{ code: '140103', name: 'Furniture & Fixtures (net)', type: 'ASSET', group: 'Fixed Assets' },
{ code: '140104', name: 'Sundry Debtors', type: 'ASSET', group: 'Other Assets' },
{ code: '140105', name: 'Advance Tax & TDS', type: 'ASSET', group: 'Other Assets' },
{ code: '140199', name: 'Opening Balance Suspense (unreconciled)', type: 'ASSET', group: 'Other Assets' },
// ── Liabilities & Capital ─────────────────────────────────────────────────
{ code: '210101', name: 'Share Capital — Individuals', type: 'LIABILITY', group: 'Capital & Reserves' },
{ code: '210201', name: 'Statutory Reserve Fund', type: 'LIABILITY', group: 'Capital & Reserves' },
{ code: '210202', name: 'Building Fund', type: 'LIABILITY', group: 'Capital & Reserves' },
{ code: '210203', name: 'Investment Depreciation Reserve', type: 'LIABILITY', group: 'Capital & Reserves' },
{ code: '210204', name: 'BDDR / Special BDDR', type: 'LIABILITY', group: 'Capital & Reserves' },
{ code: '220101', name: 'Savings Bank Deposits', type: 'LIABILITY', group: 'Deposits' },
{ code: '220102', name: 'Current Deposits', type: 'LIABILITY', group: 'Deposits' },
{ code: '220103', name: 'Term Deposits', type: 'LIABILITY', group: 'Deposits' },
{ code: '220104', name: 'Recurring Deposits', type: 'LIABILITY', group: 'Deposits' },
{ code: '230101', name: 'Interest Payable on Deposits', type: 'LIABILITY', group: 'Other Liabilities' },
{ code: '230102', name: 'Bills Payable', type: 'LIABILITY', group: 'Other Liabilities' },
{ code: '230103', name: 'Sundry Creditors', type: 'LIABILITY', group: 'Other Liabilities' },
{ code: '230104', name: 'TDS / GST Payable', type: 'LIABILITY', group: 'Other Liabilities' },
{ code: '230105', name: 'Overdue Interest Reserve', type: 'LIABILITY', group: 'Other Liabilities' },
// ── Income ────────────────────────────────────────────────────────────────
{ code: '310101', name: 'Interest on Loans & Advances', type: 'INCOME', group: 'Interest Income' },
{ code: '310102', name: 'Interest on Investments', type: 'INCOME', group: 'Interest Income' },
{ code: '310103', name: 'Interest on Inter-bank Deposits', type: 'INCOME', group: 'Interest Income' },
{ code: '320101', name: 'Commission & Exchange', type: 'INCOME', group: 'Other Income' },
{ code: '320102', name: 'Locker Rent', type: 'INCOME', group: 'Other Income' },
{ code: '320103', name: 'Processing Fees', type: 'INCOME', group: 'Other Income' },
{ code: '320104', name: 'Profit on Sale of Securities', type: 'INCOME', group: 'Other Income' },
{ code: '320105', name: 'Miscellaneous Income', type: 'INCOME', group: 'Other Income' },
// ── Expenditure ───────────────────────────────────────────────────────────
{ code: '410101', name: 'Interest on Savings Deposits', type: 'EXPENSE', group: 'Interest Expended' },
{ code: '410102', name: 'Interest on Term Deposits', type: 'EXPENSE', group: 'Interest Expended' },
{ code: '410103', name: 'Interest on Borrowings', type: 'EXPENSE', group: 'Interest Expended' },
{ code: '420101', name: 'Salaries & Allowances', type: 'EXPENSE', group: 'Operating Expenses' },
{ code: '420102', name: "Directors' Fees", type: 'EXPENSE', group: 'Operating Expenses' },
{ code: '420103', name: "Auditors' Fees", type: 'EXPENSE', group: 'Operating Expenses' },
{ code: '420104', name: 'Rent, Taxes & Lighting', type: 'EXPENSE', group: 'Operating Expenses' },
{ code: '420105', name: 'Depreciation on Bank Property', type: 'EXPENSE', group: 'Operating Expenses' },
{
code: '430101',
name: 'Printing & Stationery',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{
code: '430102',
name: 'Postage, Telegram & Telephone',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{
code: '430103',
name: 'Advertisement & Publicity',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{
code: '430104',
name: 'Repairs & Maintenance',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{
code: '430105',
name: 'Travelling & Conveyance',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{
code: '430106',
name: 'Computer & Software Expenses',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{
code: '430107',
name: 'Legal & Professional Charges',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{
code: '430108',
name: 'Insurance (DICGC & Other)',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{
code: '430109',
name: 'Bank Charges & Commission Paid',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{
code: '430110',
name: 'AGM & Meeting Expenses',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
{ code: '430111', name: 'Electricity Charges', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{
code: '430112',
name: 'Other Miscellaneous Expenses',
type: 'EXPENSE',
group: 'Miscellaneous Expenses',
miscExpense: true
},
// ── Contingent (memoranda, not part of the balance sheet totals) ──────────
{ code: '910101', name: 'Guarantees Issued — Financial', type: 'CONTINGENT', group: 'Guarantees' },
{ code: '910102', name: 'Guarantees Issued — Performance', type: 'CONTINGENT', group: 'Guarantees' },
{ code: '910103', name: 'Letters of Credit Outstanding', type: 'CONTINGENT', group: 'Letters of Credit' },
{ code: '910104', name: 'Bills for Collection', type: 'CONTINGENT', group: 'Bills & Claims' },
{ code: '910105', name: 'Claims Against Bank Not Acknowledged as Debt', type: 'CONTINGENT', group: 'Bills & Claims' },
{ code: '910106', name: 'Forward Exchange Contracts', type: 'CONTINGENT', group: 'Other Commitments' },
{ code: '910107', name: 'Capital Commitments Not Provided For', type: 'CONTINGENT', group: 'Other Commitments' }
];
/** Financial year covered by the generated ledger. */
export const GL_PERIOD = {
/** FY 2026-27 opens here. */
fyStart: '2026-04-01',
/** Postings run to the day before the app's "today" (01 Aug 2026). */
fyEnd: '2026-07-31',
label: 'FY 2026-27'
};
/**
* Opening balances as at 01-Apr-2026, keyed by account code. Positive means the
* account opens on its natural side (debit for assets/expenses, credit for the
* rest). Head Office carries the investments and reserves; branches carry the
* customer-facing book.
*/
const OPENING_BALANCES: Record<string, number> = {
'110101': 9_996_000,
'110102': 12_400_000,
'110103': 13_667_000,
// Surplus deposits parked with correspondent banks. Sized so the opening
// balance sheet tallies exactly — see the assertion in glPostings().
'110104': 54_206_000,
'120101': 63_839_000,
'120102': 147_895_000,
'120201': 22_500_000,
'120202': 24_234_000,
'130101': 166_614_000,
'130102': 125_082_000,
'130103': 70_236_000,
'130104': 29_356_000,
'130105': 10_241_000,
'130106': 8_450_000,
'140101': 33_541_000,
'140102': 3_185_000,
'140103': 1_763_000,
'140104': 1_240_000,
'140105': 657_000,
'210101': 27_278_000,
'210201': 31_149_000,
'210202': 13_642_000,
'210203': 3_750_000,
'210204': 24_341_000,
'220101': 208_116_000,
'220102': 19_592_000,
'220103': 406_601_000,
'220104': 24_880_000,
'230101': 731_000,
'230102': 291_000,
'230103': 5_515_000,
'230104': 1_120_000,
'230105': 32_096_000,
'910101': 1_450_000,
'910102': 980_000,
'910103': 620_000,
'910104': 1_180_000,
'910105': 2_638_000,
'910106': 0,
'910107': 340_000
};
/** Deterministic linear congruential generator — same figures on every load. */
function lcg(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function isoDate(d: Date): string {
const y = d.getUTCFullYear();
const m = `${d.getUTCMonth() + 1}`.padStart(2, '0');
const day = `${d.getUTCDate()}`.padStart(2, '0');
return `${y}-${m}-${day}`;
}
/** Every posting date in the ledger period, Sundays excluded (bank holiday). */
export function ledgerDates(): string[] {
const out: string[] = [];
const cur = new Date(`${GL_PERIOD.fyStart}T00:00:00Z`);
const end = new Date(`${GL_PERIOD.fyEnd}T00:00:00Z`);
while (cur <= end) {
if (cur.getUTCDay() !== 0) {
out.push(isoDate(cur));
}
cur.setUTCDate(cur.getUTCDate() + 1);
}
return out;
}
/** Templates for the vouchers the generator posts each day. */
const VOUCHER_TEMPLATES: {
debit: string;
credit: string;
narration: string;
/** Rough rupee scale of the voucher. */
scale: number;
/** Chance the voucher is raised on any given branch-day. */
frequency: number;
}[] = [
{ debit: '110101', credit: '220101', narration: 'Cash deposit — savings accounts', scale: 900_000, frequency: 1 },
{ debit: '220101', credit: '110101', narration: 'Cash withdrawal — savings accounts', scale: 780_000, frequency: 1 },
{ debit: '110101', credit: '220103', narration: 'Term deposit received', scale: 1_400_000, frequency: 0.75 },
{ debit: '220103', credit: '110101', narration: 'Term deposit matured and paid', scale: 1_150_000, frequency: 0.6 },
{ debit: '110101', credit: '220102', narration: 'Current account deposit', scale: 620_000, frequency: 0.8 },
{ debit: '220102', credit: '110101', narration: 'Current account withdrawal', scale: 560_000, frequency: 0.8 },
{ debit: '110101', credit: '220104', narration: 'Recurring deposit instalment', scale: 180_000, frequency: 0.7 },
{ debit: '130101', credit: '110101', narration: 'Cash credit disbursed', scale: 1_250_000, frequency: 0.55 },
{ debit: '110101', credit: '130101', narration: 'Cash credit repayment', scale: 1_100_000, frequency: 0.6 },
{ debit: '130102', credit: '110101', narration: 'Short term loan disbursed', scale: 850_000, frequency: 0.4 },
{ debit: '110101', credit: '130102', narration: 'Short term loan repayment', scale: 760_000, frequency: 0.5 },
{ debit: '130103', credit: '110101', narration: 'Medium term loan disbursed', scale: 640_000, frequency: 0.3 },
{ debit: '110101', credit: '130104', narration: 'Long term loan repayment', scale: 410_000, frequency: 0.35 },
{ debit: '130105', credit: '110101', narration: 'Housing loan disbursed', scale: 520_000, frequency: 0.2 },
{ debit: '130106', credit: '110101', narration: 'Gold loan disbursed', scale: 260_000, frequency: 0.4 },
// Interest on advances is the dominant earning line — sized so the bank runs a
// ~13% net margin rather than the loss the first-cut figures produced.
{ debit: '110101', credit: '310101', narration: 'Interest recovered on advances', scale: 430_000, frequency: 1 },
{ debit: '140101', credit: '310102', narration: 'Interest accrued on investments', scale: 148_000, frequency: 0.5 },
{ debit: '110103', credit: '310103', narration: 'Interest on inter-bank deposits', scale: 42_000, frequency: 0.25 },
{ debit: '110101', credit: '320101', narration: 'Commission and exchange collected', scale: 26_000, frequency: 0.9 },
{ debit: '110101', credit: '320102', narration: 'Locker rent collected', scale: 9_000, frequency: 0.3 },
{ debit: '110101', credit: '320103', narration: 'Loan processing fees', scale: 18_000, frequency: 0.5 },
{ debit: '110101', credit: '320105', narration: 'Miscellaneous income', scale: 7_500, frequency: 0.4 },
{
debit: '410101',
credit: '230101',
narration: 'Interest provided on savings deposits',
scale: 128_000,
frequency: 0.8
},
{
debit: '410102',
credit: '230101',
narration: 'Interest provided on term deposits',
scale: 265_000,
frequency: 0.9
},
{ debit: '420101', credit: '110101', narration: 'Salaries and allowances paid', scale: 210_000, frequency: 0.35 },
{ debit: '420104', credit: '110101', narration: 'Rent, taxes and lighting', scale: 46_000, frequency: 0.3 },
{ debit: '430101', credit: '110101', narration: 'Printing and stationery purchased', scale: 9_400, frequency: 0.3 },
{ debit: '430102', credit: '110101', narration: 'Postage and telephone charges', scale: 6_200, frequency: 0.35 },
{ debit: '430103', credit: '110101', narration: 'Advertisement and publicity', scale: 12_500, frequency: 0.15 },
{ debit: '430104', credit: '110101', narration: 'Repairs and maintenance', scale: 15_800, frequency: 0.25 },
{ debit: '430105', credit: '110101', narration: 'Travelling and conveyance', scale: 7_100, frequency: 0.3 },
{ debit: '430106', credit: '110101', narration: 'Computer and software expenses', scale: 22_400, frequency: 0.2 },
{ debit: '430107', credit: '110101', narration: 'Legal and professional charges', scale: 11_300, frequency: 0.15 },
{ debit: '430109', credit: '110101', narration: 'Bank charges and commission paid', scale: 4_800, frequency: 0.3 },
{ debit: '430111', credit: '110101', narration: 'Electricity charges', scale: 13_600, frequency: 0.25 },
{ debit: '430112', credit: '110101', narration: 'Other miscellaneous expenses', scale: 8_900, frequency: 0.3 },
{
debit: '110102',
credit: '110101',
narration: 'Cash remitted to RBI currency chest',
scale: 1_800_000,
frequency: 0.2
},
{
debit: '110101',
credit: '110103',
narration: 'Funds drawn from correspondent bank',
scale: 1_400_000,
frequency: 0.2
}
];
/** Head-Office-only vouchers — investments, reserves and centralised charges. */
const HO_TEMPLATES: typeof VOUCHER_TEMPLATES = [
{ debit: '120101', credit: '110102', narration: 'Purchase of Govt securities', scale: 2_400_000, frequency: 0.12 },
{
debit: '120102',
credit: '110102',
narration: 'Purchase of State Govt securities',
scale: 1_900_000,
frequency: 0.12
},
{ debit: '110102', credit: '320104', narration: 'Profit on sale of securities', scale: 78_000, frequency: 0.1 },
{ debit: '420102', credit: '110101', narration: "Directors' sitting fees", scale: 32_000, frequency: 0.08 },
{ debit: '420103', credit: '110101', narration: "Statutory auditors' fees", scale: 45_000, frequency: 0.06 },
{ debit: '420105', credit: '140103', narration: 'Depreciation charged on assets', scale: 64_000, frequency: 0.12 },
{ debit: '430108', credit: '110101', narration: 'DICGC premium and other insurance', scale: 38_000, frequency: 0.1 },
{ debit: '430110', credit: '110101', narration: 'AGM and meeting expenses', scale: 26_000, frequency: 0.06 },
{ debit: '410103', credit: '230101', narration: 'Interest on borrowings', scale: 54_000, frequency: 0.1 },
{ debit: '230104', credit: '110102', narration: 'TDS and GST remitted', scale: 145_000, frequency: 0.15 }
];
/**
* Contingent-account movements are memoranda, each posted single-sided.
* Guarantees, LCs and collection bills are raised at the counter, so these run
* at every branch — not just Head Office.
*/
const CONTINGENT_TEMPLATES: { account: string; narration: string; scale: number; frequency: number }[] = [
{ account: '910101', narration: 'Financial guarantee issued', scale: 320_000, frequency: 0.1 },
{ account: '910102', narration: 'Performance guarantee issued', scale: 240_000, frequency: 0.08 },
{ account: '910103', narration: 'Letter of credit opened', scale: 180_000, frequency: 0.07 },
{ account: '910104', narration: 'Bills lodged for collection', scale: 95_000, frequency: 0.12 },
{ account: '910105', narration: 'Claim lodged against the bank', scale: 140_000, frequency: 0.03 },
{ account: '910107', narration: 'Capital commitment contracted', scale: 85_000, frequency: 0.04 }
];
/**
* How the opening contingent book is spread across the network. Head Office
* carries the capital commitments and large claims; the branches carry the
* counter-issued guarantees. Weights are applied per account and the rounding
* remainder is absorbed by Head Office so each account's total stays exact.
*/
const CONTINGENT_BRANCH_WEIGHTS: Record<string, number> = {
HO: 0.2,
'0002': 0.18,
'0003': 0.16,
'0004': 0.16,
'0005': 0.15,
'0006': 0.15
};
let cachedPostings: GlPosting[] | null = null;
/**
* The full generated ledger. Built once and memoised — every report reads from
* this array, so the reports can never disagree with one another.
*/
export function glPostings(): GlPosting[] {
if (cachedPostings) {
return cachedPostings;
}
const rand = lcg(20260401);
const out: GlPosting[] = [];
const dates = ledgerDates();
let voucherSeq = 1;
// Opening entries, posted at Head Office on day one so the books start in balance.
const openingVoucher = `OB/${GL_PERIOD.label.replace(/\s/g, '')}`;
let openingDebit = 0;
let openingCredit = 0;
for (const [
code,
amount
] of Object.entries(OPENING_BALANCES)) {
if (!amount) {
continue;
}
const account = GL_ACCOUNTS.find((a) => a.code === code);
if (!account) {
continue;
}
const debitSide = account.type === 'ASSET' || account.type === 'EXPENSE' || account.type === 'CONTINGENT';
// Contingent accounts are single-sided memoranda held at the issuing branch,
// so their opening book is spread across the network rather than sat at HO.
if (account.type === 'CONTINGENT') {
let allocated = 0;
for (const branch of GL_BRANCHES) {
if (branch.code === 'HO') {
continue;
}
const share = Math.round((amount * (CONTINGENT_BRANCH_WEIGHTS[branch.code] ?? 0)) / 100) * 100;
if (!share) {
continue;
}
allocated += share;
out.push({
date: GL_PERIOD.fyStart,
branchCode: branch.code,
accountCode: code,
debit: share,
credit: 0,
voucherNo: openingVoucher,
narration: 'Opening balance brought forward'
});
}
// Head Office takes its own share plus whatever rounding left behind.
out.push({
date: GL_PERIOD.fyStart,
branchCode: 'HO',
accountCode: code,
debit: amount - allocated,
credit: 0,
voucherNo: openingVoucher,
narration: 'Opening balance brought forward'
});
continue;
}
out.push({
date: GL_PERIOD.fyStart,
branchCode: 'HO',
accountCode: code,
debit: debitSide ? amount : 0,
credit: debitSide ? 0 : amount,
voucherNo: openingVoucher,
narration: 'Opening balance brought forward'
});
if (debitSide) {
openingDebit += amount;
} else {
openingCredit += amount;
}
}
// If OPENING_BALANCES is ever edited so the two sides diverge, park the residual
// in a visibly-named suspense account rather than letting the trial balance drift.
const openingResidual = openingCredit - openingDebit;
if (openingResidual !== 0) {
out.push({
date: GL_PERIOD.fyStart,
branchCode: 'HO',
accountCode: '140199',
debit: openingResidual > 0 ? openingResidual : 0,
credit: openingResidual < 0 ? -openingResidual : 0,
voucherNo: openingVoucher,
narration: 'Unreconciled opening balance difference'
});
}
const round = (n: number): number => Math.round(n / 100) * 100;
for (const date of dates) {
for (const branch of GL_BRANCHES) {
// Head Office posts fewer counter transactions than the branches.
const weight = branch.code === 'HO' ? 0.45 : 1;
const templates = branch.code === 'HO' ? [
...VOUCHER_TEMPLATES,
...HO_TEMPLATES
] : VOUCHER_TEMPLATES;
for (const tpl of templates) {
if (rand() > tpl.frequency * weight) {
continue;
}
// 0.55x .. 1.45x of the template scale keeps daily figures varied but plausible.
const amount = round(tpl.scale * (0.55 + rand() * 0.9));
if (amount <= 0) {
continue;
}
const voucherNo = `${branch.code}/${date.slice(5, 7)}/${`${voucherSeq++}`.padStart(5, '0')}`;
out.push({
date,
branchCode: branch.code,
accountCode: tpl.debit,
debit: amount,
credit: 0,
voucherNo,
narration: tpl.narration
});
out.push({
date,
branchCode: branch.code,
accountCode: tpl.credit,
debit: 0,
credit: amount,
voucherNo,
narration: tpl.narration
});
}
// Contingent memoranda are raised at every branch, weighted by its size.
const contingentWeight = CONTINGENT_BRANCH_WEIGHTS[branch.code] ?? 0.15;
for (const tpl of CONTINGENT_TEMPLATES) {
// Claims against the bank and capital commitments are Head Office matters.
const headOfficeOnly = tpl.account === '910105' || tpl.account === '910107';
if (headOfficeOnly && branch.code !== 'HO') {
continue;
}
const frequency = headOfficeOnly ? tpl.frequency : tpl.frequency * (contingentWeight / 0.2);
if (rand() > frequency) {
continue;
}
const amount = round(tpl.scale * (0.55 + rand() * 0.9));
out.push({
date,
branchCode: branch.code,
accountCode: tpl.account,
debit: amount,
credit: 0,
voucherNo: `CNT/${branch.code}/${date.slice(5, 7)}/${`${voucherSeq++}`.padStart(5, '0')}`,
narration: tpl.narration
});
}
}
}
cachedPostings = out;
return out;
}
/**
* Customer sub-ledger.
*
* The GL is account-level, so a client-wise report needs a customer dimension on top.
* Each customer holds a share of one GL head at one branch; balances are not stored,
* they are derived at query time as `share x that head's GL balance`. That way the
* client-wise report always reconciles to the General Ledger exactly, whatever period
* or branch is selected, instead of drifting as a second source of truth.
*/
export interface GlCustomer {
cif: string;
name: string;
branchCode: string;
/** GL head this relationship sits under. */
accountCode: string;
accountNo: string;
/** Fraction of the branch's balance in that head. Shares per (branch, head) sum to 1. */
share: number;
}
const FIRST_NAMES = [
'Ravi',
'Priya',
'Vikram',
'Nirav',
'Kavita',
'Arjun',
'Lakshmi',
'Sameer',
'Anjali',
'Rohan',
'Meera',
'Karan',
'Divya',
'Sanjay',
'Pooja',
'Amit',
'Sneha',
'Manish',
'Rekha',
'Deepak'
];
const LAST_NAMES = [
'Sharma',
'Patel',
'Reddy',
'Shah',
'Verma',
'Deshmukh',
'Iyer',
'Kapoor',
'Nair',
'Joshi',
'Mehta',
'Gupta',
'Rao',
'Bhatt',
'Chauhan'
];
const FIRMS = [
'Traders',
'Enterprises',
'Agencies',
'& Sons',
'Industries',
'Stores',
'Corporation'
];
/** Customer-facing heads a client relationship can sit under. */
const CUSTOMER_HEADS = [
'220101',
'220102',
'220103',
'220104',
'130101',
'130102',
'130103',
'130104',
'130105',
'130106'
];
let cachedCustomers: GlCustomer[] | null = null;
export function glCustomers(): GlCustomer[] {
if (cachedCustomers) {
return cachedCustomers;
}
const rand = lcg(76543210);
const out: GlCustomer[] = [];
let seq = 1;
for (const branch of GL_BRANCHES) {
for (const head of CUSTOMER_HEADS) {
// Deposits are held by many customers; advances by fewer, larger ones.
const isAdvance = head.startsWith('13');
const count = isAdvance ? 2 + Math.floor(rand() * 3) : 3 + Math.floor(rand() * 4);
const weights: number[] = [];
for (let i = 0; i < count; i++) {
weights.push(0.35 + rand() * 0.65);
}
const weightTotal = weights.reduce((a, b) => a + b, 0);
for (let i = 0; i < count; i++) {
const corporate = isAdvance && rand() > 0.55;
const name = corporate
? `${LAST_NAMES[Math.floor(rand() * LAST_NAMES.length)]} ${FIRMS[Math.floor(rand() * FIRMS.length)]}`
: `${FIRST_NAMES[Math.floor(rand() * FIRST_NAMES.length)]} ${LAST_NAMES[Math.floor(rand() * LAST_NAMES.length)]}`;
out.push({
cif: `CIF${`${100000 + seq}`}`,
name,
branchCode: branch.code,
accountCode: head,
accountNo: `${branch.code === 'HO' ? '0001' : branch.code}${head}${`${seq}`.padStart(4, '0')}`,
share: weights[i] / weightTotal
});
seq++;
}
}
}
cachedCustomers = out;
return out;
}
export function glAccount(code: string): GlAccount | undefined {
return GL_ACCOUNTS.find((a) => a.code === code);
}
export function glBranchName(code: string): string {
return GL_BRANCHES.find((b) => b.code === code)?.name ?? code;
}
@@ -0,0 +1,34 @@
/**
* 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 { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
const routes: Routes = [
Route.withShell([
{
path: 'gl/reports',
// Lazy — the generated ledger and ExcelJS should not weigh down the eager bundle.
loadComponent: () => import('./gl-reports.component').then((m) => m.GlReportsComponent),
data: { title: 'General Ledger Reports', breadcrumb: 'GL Reports' }
},
{
path: 'gl/reports/:report',
loadComponent: () => import('./gl-reports.component').then((m) => m.GlReportsComponent),
data: { title: 'General Ledger Reports', breadcrumb: 'GL Reports' }
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class GlReportsRoutingModule {}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,560 @@
/**
* 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 {
--g-navy: #1a237e;
--g-blue: #1d4ed8;
--g-blue-l: #eff6ff;
--g-teal: #0e7e6b;
--g-teal-l: #e0f4f0;
--g-green: #15803d;
--g-green-l: #dcfce7;
--g-amber: #b45309;
--g-amber-l: #fef3c7;
--g-red: #c0392b;
--g-red-l: #fdecea;
--g-g50: #f8fafc;
--g-g100: #f1f5f9;
--g-g200: #e2e8f0;
--g-g300: #cbd5e1;
--g-g500: #64748b;
--g-g700: #334155;
--g-g900: #0f172a;
display: block;
font-family:
'DM Sans',
system-ui,
-apple-system,
sans-serif;
background: var(--g-g50);
color: var(--g-g900);
font-size: 14px;
line-height: 1.5;
}
.glr-root {
padding: 18px 22px;
max-width: 1700px;
margin: 0 auto;
}
/* HEADER */
.glr-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.glr-head-left {
display: flex;
align-items: center;
gap: 14px;
}
.glr-head-ic {
font-size: 28px;
color: var(--g-navy);
}
.glr-head-t {
margin: 0;
font-size: 18px;
font-weight: 700;
}
.glr-head-s {
font-size: 12px;
color: var(--g-g500);
}
/* BUTTONS */
.glr-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 16px;
border: 1px solid var(--g-g300);
background: #fff;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: var(--g-g700);
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
white-space: nowrap;
&:hover:not(:disabled) {
background: var(--g-g50);
border-color: var(--g-g500);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.glr-btn-xls {
background: #16a34a;
border-color: #16a34a;
color: #fff;
&:hover:not(:disabled) {
background: #15803d;
border-color: #15803d;
color: #fff;
}
}
/* FILTER BAR */
.glr-filters {
background: #fff;
border: 1px solid var(--g-g200);
border-radius: 12px;
padding: 14px;
display: flex;
align-items: flex-end;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgb(0 0 0 / 4%);
}
.glr-filter {
display: flex;
flex-direction: column;
gap: 5px;
label {
font-size: 11px;
font-weight: 700;
color: var(--g-g500);
text-transform: uppercase;
letter-spacing: 0.05em;
}
}
.glr-inp {
padding: 8px 12px;
border: 1px solid var(--g-g200);
border-radius: 8px;
font-size: 13px;
background: #fff;
font-family: inherit;
min-width: 190px;
&:focus {
outline: none;
border-color: var(--g-navy);
box-shadow: 0 0 0 3px rgb(26 35 126 / 12%);
}
}
/* Read-only branch shown to a branch-level login in place of the selector. */
.glr-chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 8px 14px;
min-width: 190px;
border: 1px solid var(--g-g200);
border-radius: 8px;
background: var(--g-g100);
font-size: 13px;
font-weight: 600;
color: var(--g-g700);
}
/* Day / Month / Range segmented control */
.glr-seg {
display: inline-flex;
border: 1px solid var(--g-g200);
border-radius: 8px;
overflow: hidden;
button {
padding: 8px 14px;
border: none;
background: #fff;
font-size: 13px;
font-weight: 600;
color: var(--g-g500);
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
+ button {
border-left: 1px solid var(--g-g200);
}
&:hover {
background: var(--g-g50);
}
&.active {
background: var(--g-navy);
color: #fff;
}
}
}
/* REPORT SELECTOR */
.glr-tabs {
display: flex;
gap: 4px;
background: #fff;
border: 1px solid var(--g-g200);
border-radius: 10px;
padding: 4px;
margin-bottom: 16px;
overflow-x: auto;
}
.glr-tab {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 14px;
border: none;
background: transparent;
font-size: 13px;
font-weight: 500;
color: var(--g-g500);
cursor: pointer;
border-radius: 7px;
white-space: nowrap;
transition: all 0.15s;
font-family: inherit;
&:hover {
color: var(--g-g900);
}
&.active {
background: var(--g-navy);
color: #fff;
box-shadow: 0 2px 6px rgb(26 35 126 / 25%);
}
}
.glr-section {
display: flex;
flex-direction: column;
gap: 16px;
}
/* BALANCE BANNER */
.glr-balance {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
border-radius: 10px;
background: var(--g-green-l);
border: 1px solid #86efac;
color: #14532d;
font-size: 13px;
&--bad {
background: var(--g-red-l);
border-color: #fca5a5;
color: #7f1d1d;
}
}
.glr-note {
margin: 0;
font-size: 12px;
color: var(--g-g500);
}
/* KPIs */
.glr-kpis {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.glr-kpi {
background: #fff;
border: 1px solid var(--g-g200);
border-left: 4px solid var(--g-g300);
border-radius: 12px;
padding: 14px 18px;
box-shadow: 0 1px 3px rgb(0 0 0 / 5%);
&--teal {
border-left-color: var(--g-teal);
}
&--amber {
border-left-color: var(--g-amber);
}
&--green {
border-left-color: var(--g-green);
}
&--red {
border-left-color: var(--g-red);
}
}
.glr-kpi-n {
font-size: 20px;
font-weight: 700;
font-family: 'DM Mono', monospace;
color: var(--g-g900);
}
.glr-kpi-l {
font-size: 12px;
color: var(--g-g700);
font-weight: 600;
margin-top: 2px;
}
.glr-kpi-sub {
font-size: 11px;
color: var(--g-g500);
}
/* TABLES */
.glr-two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
align-items: start;
}
.glr-tbl-wrap {
background: #fff;
border: 1px solid var(--g-g200);
border-radius: 12px;
overflow: auto;
box-shadow: 0 1px 3px rgb(0 0 0 / 4%);
&--tall {
max-height: 70vh;
}
}
.glr-tbl-h {
background: var(--g-blue-l);
color: var(--g-navy);
padding: 10px 16px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid var(--g-g200);
position: sticky;
top: 0;
z-index: 2;
}
.glr-tbl {
width: 100%;
border-collapse: collapse;
th {
text-align: left;
padding: 9px 12px;
font-size: 11px;
font-weight: 700;
color: var(--g-g500);
text-transform: uppercase;
letter-spacing: 0.04em;
background: var(--g-g50);
border-bottom: 1px solid var(--g-g200);
white-space: nowrap;
}
td {
padding: 8px 12px;
border-bottom: 1px solid var(--g-g100);
font-size: 13px;
color: var(--g-g700);
}
tbody tr:hover td {
background: var(--g-g50);
}
tfoot td {
position: sticky;
bottom: 0;
}
}
.total-row td {
background: var(--g-g100) !important;
font-weight: 700;
color: var(--g-g900);
border-top: 2px solid var(--g-g300);
}
.glr-profit-row td {
background: var(--g-green-l) !important;
color: #14532d;
}
.glr-loss-row td {
background: var(--g-red-l) !important;
color: #7f1d1d;
}
.ta-c {
text-align: center;
}
.ta-r {
text-align: right;
}
.mono {
font-family: 'DM Mono', monospace;
font-size: 12px;
}
.muted {
color: var(--g-g500);
font-size: 11px;
}
.var-up {
color: var(--g-green);
font-weight: 600;
}
.var-down {
color: var(--g-red);
font-weight: 600;
}
.glr-empty {
text-align: center;
padding: 34px;
color: var(--g-g500);
font-style: italic;
}
/* A voucher whose debits and credits disagree. */
.glr-unbalanced td {
background: var(--g-red-l) !important;
color: #7f1d1d;
font-weight: 600;
}
/* GL code chips on the mapping report */
.glr-gl-chip {
display: inline-block;
padding: 1px 8px;
margin: 1px 3px 1px 0;
border-radius: 999px;
background: #e0f2fe;
color: #075985;
font-size: 11px;
font-weight: 600;
}
/* Share bars on the distribution report */
.glr-bar-col {
width: 150px;
}
.glr-bar {
height: 9px;
border-radius: 999px;
background: var(--g-g100);
overflow: hidden;
min-width: 110px;
}
.glr-bar-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--g-navy), var(--g-blue));
transition: width 0.25s;
}
/* Account-type pills on the GL Wise report */
.type-pill {
display: inline-block;
padding: 2px 9px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.03em;
background: var(--g-g100);
color: var(--g-g700);
&.type-asset {
background: var(--g-blue-l);
color: var(--g-blue);
}
&.type-liability {
background: var(--g-teal-l);
color: var(--g-teal);
}
&.type-income {
background: var(--g-green-l);
color: var(--g-green);
}
&.type-expense {
background: var(--g-amber-l);
color: var(--g-amber);
}
&.type-contingent {
background: var(--g-red-l);
color: var(--g-red);
}
}
/* TOAST */
.glr-toast {
position: fixed;
bottom: 32px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--g-navy);
color: #fff;
padding: 12px 22px;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
box-shadow: 0 6px 20px rgb(0 0 0 / 25%);
opacity: 0;
pointer-events: none;
transition: all 0.25s;
z-index: 1500;
&.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
@media (width <= 1200px) {
.glr-two-col {
grid-template-columns: 1fr;
}
.glr-kpis {
grid-template-columns: 1fr;
}
}
File diff suppressed because it is too large Load Diff
+843
View File
@@ -0,0 +1,843 @@
/**
* 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/.
*/
/**
* Derives all seven General Ledger reports from the posting ledger.
*
* Each report is a pure function of (postings, date range, branch), so changing
* the day / month / branch filter genuinely recomputes the figures. Results are
* memoised per filter key because the ledger runs to roughly twenty thousand
* postings and Angular re-evaluates template getters on every change detection.
*/
import { GL_ACCOUNTS, GL_PERIOD, GlAccount, GlPosting, glAccount, glCustomers, glPostings } from './gl-reports-data';
export interface GlDateRange {
from: string;
to: string;
}
export interface TrialBalanceRow {
code: string;
name: string;
group: string;
openingDebit: number;
openingCredit: number;
periodDebit: number;
periodCredit: number;
closingDebit: number;
closingCredit: number;
}
export interface PnlRow {
code: string;
name: string;
group: string;
amount: number;
}
export interface BalanceSheetRow {
code: string;
name: string;
group: string;
amount: number;
}
export interface ComparativeRow {
code: string;
name: string;
group: string;
side: 'ASSET' | 'LIABILITY';
current: number;
previous: number;
variance: number;
variancePct: number | null;
}
export interface GlWiseRow {
code: string;
name: string;
group: string;
type: string;
debit: number;
credit: number;
net: number;
vouchers: number;
}
export interface MiscExpenseRow {
date: string;
voucherNo: string;
branchCode: string;
code: string;
name: string;
narration: string;
amount: number;
}
/** Natural side of an account — assets and expenses sit on the debit side. */
function isDebitSide(account: GlAccount): boolean {
return account.type === 'ASSET' || account.type === 'EXPENSE' || account.type === 'CONTINGENT';
}
function matchesBranch(p: GlPosting, branchCode: string): boolean {
return !branchCode || p.branchCode === branchCode;
}
/**
* Both scans below walk the whole ~21k-posting ledger, so their results are cached
* per (date, branch). Angular re-evaluates template getters on every change-detection
* pass; without this a screen with a few hundred mapped lines would spend seconds per
* click rescanning the ledger and appear frozen.
*/
const balanceCache = new Map<string, Map<string, number>>();
const turnoverCache = new Map<string, Map<string, { debit: number; credit: number; vouchers: Set<string> }>>();
/** Signed balance on the account's natural side, for postings up to and including `to`. */
function cumulativeBalances(to: string, branchCode: string): Map<string, number> {
const key = `${to}|${branchCode}`;
const hit = balanceCache.get(key);
if (hit) {
return hit;
}
const out = new Map<string, number>();
for (const p of glPostings()) {
if (p.date > to || !matchesBranch(p, branchCode)) {
continue;
}
const account = glAccount(p.accountCode);
if (!account) {
continue;
}
const delta = isDebitSide(account) ? p.debit - p.credit : p.credit - p.debit;
out.set(p.accountCode, (out.get(p.accountCode) ?? 0) + delta);
}
balanceCache.set(key, out);
return out;
}
/** Debit and credit turnover strictly inside the range. */
function periodTurnover(
range: GlDateRange,
branchCode: string
): Map<string, { debit: number; credit: number; vouchers: Set<string> }> {
const key = `${range.from}|${range.to}|${branchCode}`;
const hit = turnoverCache.get(key);
if (hit) {
return hit;
}
const out = new Map<string, { debit: number; credit: number; vouchers: Set<string> }>();
for (const p of glPostings()) {
if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) {
continue;
}
let entry = out.get(p.accountCode);
if (!entry) {
entry = { debit: 0, credit: 0, vouchers: new Set<string>() };
out.set(p.accountCode, entry);
}
entry.debit += p.debit;
entry.credit += p.credit;
entry.vouchers.add(p.voucherNo);
}
turnoverCache.set(key, out);
return out;
}
/** The day before `date`, ISO formatted — used for opening balances. */
function previousDay(date: string): string {
const d = new Date(`${date}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() - 1);
return d.toISOString().slice(0, 10);
}
// ── Reports ──────────────────────────────────────────────────────────────────
export function trialBalance(range: GlDateRange, branchCode: string): TrialBalanceRow[] {
const opening = cumulativeBalances(previousDay(range.from), branchCode);
const turnover = periodTurnover(range, branchCode);
const rows: TrialBalanceRow[] = [];
for (const account of GL_ACCOUNTS) {
// Contingent accounts are memoranda and never appear in the trial balance.
if (account.type === 'CONTINGENT') {
continue;
}
const open = opening.get(account.code) ?? 0;
const move = turnover.get(account.code);
const periodDebit = move?.debit ?? 0;
const periodCredit = move?.credit ?? 0;
if (!open && !periodDebit && !periodCredit) {
continue;
}
const debitSide = isDebitSide(account);
const closingNatural = open + (debitSide ? periodDebit - periodCredit : periodCredit - periodDebit);
const closingSigned = debitSide ? closingNatural : -closingNatural;
rows.push({
code: account.code,
name: account.name,
group: account.group,
openingDebit: debitSide && open > 0 ? open : !debitSide && open < 0 ? -open : 0,
openingCredit: !debitSide && open > 0 ? open : debitSide && open < 0 ? -open : 0,
periodDebit,
periodCredit,
closingDebit: closingSigned > 0 ? closingSigned : 0,
closingCredit: closingSigned < 0 ? -closingSigned : 0
});
}
return rows;
}
export function profitAndLoss(range: GlDateRange, branchCode: string): { income: PnlRow[]; expense: PnlRow[] } {
const turnover = periodTurnover(range, branchCode);
const income: PnlRow[] = [];
const expense: PnlRow[] = [];
for (const account of GL_ACCOUNTS) {
if (account.type !== 'INCOME' && account.type !== 'EXPENSE') {
continue;
}
const move = turnover.get(account.code);
if (!move) {
continue;
}
const amount = account.type === 'INCOME' ? move.credit - move.debit : move.debit - move.credit;
if (!amount) {
continue;
}
const row: PnlRow = { code: account.code, name: account.name, group: account.group, amount };
(account.type === 'INCOME' ? income : expense).push(row);
}
return { income, expense };
}
/**
* Balance sheet as at the period end. The current-period surplus is folded in as
* a liability line so the two sides tally the way the printed statement does.
*/
export function balanceSheet(
range: GlDateRange,
branchCode: string
): {
assets: BalanceSheetRow[];
liabilities: BalanceSheetRow[];
surplus: number;
} {
const closing = cumulativeBalances(range.to, branchCode);
const assets: BalanceSheetRow[] = [];
const liabilities: BalanceSheetRow[] = [];
let income = 0;
let expense = 0;
for (const account of GL_ACCOUNTS) {
const bal = closing.get(account.code) ?? 0;
if (account.type === 'INCOME') {
income += bal;
continue;
}
if (account.type === 'EXPENSE') {
expense += bal;
continue;
}
if (account.type === 'CONTINGENT' || !bal) {
continue;
}
const row: BalanceSheetRow = { code: account.code, name: account.name, group: account.group, amount: bal };
(account.type === 'ASSET' ? assets : liabilities).push(row);
}
return { assets, liabilities, surplus: income - expense };
}
/** Current period against the equivalent span immediately before it. */
export function comparativeBalanceSheet(range: GlDateRange, branchCode: string): ComparativeRow[] {
const priorTo = previousDay(range.from);
const current = cumulativeBalances(range.to, branchCode);
const previous = cumulativeBalances(priorTo, branchCode);
const rows: ComparativeRow[] = [];
for (const account of GL_ACCOUNTS) {
if (account.type !== 'ASSET' && account.type !== 'LIABILITY') {
continue;
}
const cur = current.get(account.code) ?? 0;
const prev = previous.get(account.code) ?? 0;
if (!cur && !prev) {
continue;
}
const variance = cur - prev;
rows.push({
code: account.code,
name: account.name,
group: account.group,
side: account.type,
current: cur,
previous: prev,
variance,
variancePct: prev ? (variance / Math.abs(prev)) * 100 : null
});
}
return rows;
}
export function contingentLiabilities(range: GlDateRange, branchCode: string): BalanceSheetRow[] {
const closing = cumulativeBalances(range.to, branchCode);
return GL_ACCOUNTS.filter((a) => a.type === 'CONTINGENT')
.map((a) => ({ code: a.code, name: a.name, group: a.group, amount: closing.get(a.code) ?? 0 }))
.filter((r) => r.amount !== 0);
}
export function glWiseDrCr(range: GlDateRange, branchCode: string): GlWiseRow[] {
const turnover = periodTurnover(range, branchCode);
const rows: GlWiseRow[] = [];
for (const account of GL_ACCOUNTS) {
const move = turnover.get(account.code);
if (!move || (!move.debit && !move.credit)) {
continue;
}
rows.push({
code: account.code,
name: account.name,
group: account.group,
type: account.type,
debit: move.debit,
credit: move.credit,
net: move.debit - move.credit,
vouchers: move.vouchers.size
});
}
return rows;
}
export function miscExpenseLedger(range: GlDateRange, branchCode: string): MiscExpenseRow[] {
const miscCodes = new Set(GL_ACCOUNTS.filter((a) => a.miscExpense).map((a) => a.code));
const rows: MiscExpenseRow[] = [];
for (const p of glPostings()) {
if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) {
continue;
}
if (!miscCodes.has(p.accountCode) || !p.debit) {
continue;
}
rows.push({
date: p.date,
voucherNo: p.voucherNo,
branchCode: p.branchCode,
code: p.accountCode,
name: glAccount(p.accountCode)?.name ?? p.accountCode,
narration: p.narration,
amount: p.debit
});
}
return rows.sort((a, b) =>
a.date === b.date ? a.voucherNo.localeCompare(b.voucherNo) : a.date.localeCompare(b.date)
);
}
export interface AverageBalanceRow {
code: string;
name: string;
group: string;
type: string;
opening: number;
closing: number;
average: number;
peak: number;
lowest: number;
/** Days on which the account moved. */
activeDays: number;
}
export interface AccountBranchRow {
code: string;
name: string;
group: string;
/** Closing balance per branch code, plus a `total`. */
byBranch: Record<string, number>;
total: number;
}
export interface DistributionRow {
group: string;
amount: number;
share: number;
accounts: number;
}
/** Calendar days in the range, inclusive. */
function daysInRange(range: GlDateRange): string[] {
const out: string[] = [];
const cur = new Date(`${range.from}T00:00:00Z`);
const end = new Date(`${range.to}T00:00:00Z`);
while (cur <= end) {
out.push(cur.toISOString().slice(0, 10));
cur.setUTCDate(cur.getUTCDate() + 1);
}
return out;
}
/**
* Average daily balance per account across the period, plus the peak and lowest
* balance touched. Banks use average balances rather than closing snapshots for
* interest, CRR/SLR and profitability work, because a closing figure can be
* window-dressed on the last day.
*
* Computed by carrying a running balance forward day by day rather than
* recomputing a cumulative balance per day, which would be quadratic.
*/
export function averageBalances(range: GlDateRange, branchCode: string): AverageBalanceRow[] {
const opening = cumulativeBalances(previousDay(range.from), branchCode);
const days = daysInRange(range);
// Net movement per account per day, on the account's natural side.
const dailyDelta = new Map<string, Map<string, number>>();
const movedOn = new Map<string, Set<string>>();
for (const p of glPostings()) {
if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) {
continue;
}
const account = glAccount(p.accountCode);
if (!account) {
continue;
}
const delta = isDebitSide(account) ? p.debit - p.credit : p.credit - p.debit;
let perDay = dailyDelta.get(p.accountCode);
if (!perDay) {
perDay = new Map<string, number>();
dailyDelta.set(p.accountCode, perDay);
}
perDay.set(p.date, (perDay.get(p.date) ?? 0) + delta);
let seen = movedOn.get(p.accountCode);
if (!seen) {
seen = new Set<string>();
movedOn.set(p.accountCode, seen);
}
seen.add(p.date);
}
const rows: AverageBalanceRow[] = [];
for (const account of GL_ACCOUNTS) {
const open = opening.get(account.code) ?? 0;
const perDay = dailyDelta.get(account.code);
if (!open && !perDay) {
continue;
}
let running = open;
let total = 0;
let peak = open;
let lowest = open;
for (const d of days) {
running += perDay?.get(d) ?? 0;
total += running;
peak = Math.max(peak, running);
lowest = Math.min(lowest, running);
}
rows.push({
code: account.code,
name: account.name,
group: account.group,
type: account.type,
opening: open,
closing: running,
average: days.length ? total / days.length : 0,
peak,
lowest,
activeDays: movedOn.get(account.code)?.size ?? 0
});
}
return rows;
}
/**
* Closing balance of every account broken out by branch. Answers "which branch is
* carrying this balance", which a single consolidated column cannot.
*/
export function accountWiseByBranch(range: GlDateRange, branchCodes: string[]): AccountBranchRow[] {
const perBranch = new Map<string, Map<string, number>>();
for (const b of branchCodes) {
perBranch.set(b, cumulativeBalances(range.to, b));
}
const rows: AccountBranchRow[] = [];
for (const account of GL_ACCOUNTS) {
const byBranch: Record<string, number> = {};
let total = 0;
let any = false;
for (const b of branchCodes) {
const v = perBranch.get(b)?.get(account.code) ?? 0;
byBranch[b] = v;
total += v;
if (v) {
any = true;
}
}
if (!any) {
continue;
}
rows.push({ code: account.code, name: account.name, group: account.group, byBranch, total });
}
return rows;
}
/**
* Share of the closing book held by each account group, for one side of the
* balance sheet. Used to see concentration at a glance.
*/
export function balanceDistribution(
range: GlDateRange,
branchCode: string,
side: 'ASSET' | 'LIABILITY'
): DistributionRow[] {
const closing = cumulativeBalances(range.to, branchCode);
const byGroup = new Map<string, { amount: number; accounts: number }>();
let grand = 0;
for (const account of GL_ACCOUNTS) {
if (account.type !== side) {
continue;
}
const v = closing.get(account.code) ?? 0;
if (!v) {
continue;
}
const entry = byGroup.get(account.group) ?? { amount: 0, accounts: 0 };
entry.amount += v;
entry.accounts += 1;
byGroup.set(account.group, entry);
grand += v;
}
return [...byGroup.entries()]
.map(
([
group,
e
]) => ({
group,
amount: e.amount,
accounts: e.accounts,
share: grand ? (e.amount / grand) * 100 : 0
})
)
.sort((a, b) => b.amount - a.amount);
}
export interface ClientBalanceRow {
cif: string;
name: string;
branchCode: string;
accountNo: string;
accountCode: string;
product: string;
/** DEPOSIT or ADVANCE — which side of the relationship this is. */
side: 'DEPOSIT' | 'ADVANCE';
balance: number;
}
export interface BankerHeadRow {
head: string;
opening: number;
debit: number;
credit: number;
closing: number;
accounts: number;
}
export interface SundryItemRow {
date: string;
voucherNo: string;
branchCode: string;
code: string;
name: string;
narration: string;
amount: number;
/** Days outstanding as at the period end. */
ageDays: number;
bucket: string;
}
export interface VoucherRow {
voucherNo: string;
date: string;
branchCode: string;
narration: string;
debitAccounts: string;
creditAccounts: string;
amount: number;
legs: number;
/** False when debits and credits on the voucher do not agree. */
balanced: boolean;
}
/**
* Client-wise balances, derived from the GL rather than stored separately so the two
* can never disagree. Each customer's balance is its share of the branch's closing
* balance in the head it sits under.
*/
export function clientWiseBalances(range: GlDateRange, branchCode: string): ClientBalanceRow[] {
const rows: ClientBalanceRow[] = [];
const branchClosings = new Map<string, Map<string, number>>();
for (const c of glCustomers()) {
if (branchCode && c.branchCode !== branchCode) {
continue;
}
let closing = branchClosings.get(c.branchCode);
if (!closing) {
closing = cumulativeBalances(range.to, c.branchCode);
branchClosings.set(c.branchCode, closing);
}
const headBalance = closing.get(c.accountCode) ?? 0;
const balance = headBalance * c.share;
if (Math.abs(balance) < 0.005) {
continue;
}
const account = glAccount(c.accountCode);
rows.push({
cif: c.cif,
name: c.name,
branchCode: c.branchCode,
accountNo: c.accountNo,
accountCode: c.accountCode,
product: account?.name ?? c.accountCode,
side: c.accountCode.startsWith('13') ? 'ADVANCE' : 'DEPOSIT',
balance
});
}
return rows.sort((a, b) => b.balance - a.balance);
}
/**
* Head-wise abstract: the trial balance rolled up to principal heads, the summary a
* banker reads before the detail. Opening, turnover and closing per head.
*/
export function bankerHeadSummary(range: GlDateRange, branchCode: string): BankerHeadRow[] {
const opening = cumulativeBalances(previousDay(range.from), branchCode);
const turnover = periodTurnover(range, branchCode);
const byHead = new Map<string, BankerHeadRow>();
for (const account of GL_ACCOUNTS) {
// Contingents are single-sided off-balance-sheet memoranda. Including them would
// make the abstract's debit and credit turnover disagree and stop it reconciling
// to the trial balance, which also excludes them. They have their own report.
if (account.type === 'CONTINGENT') {
continue;
}
const open = opening.get(account.code) ?? 0;
const move = turnover.get(account.code);
if (!open && !move) {
continue;
}
const debit = move?.debit ?? 0;
const credit = move?.credit ?? 0;
const closing = open + (isDebitSide(account) ? debit - credit : credit - debit);
const row = byHead.get(account.group) ?? {
head: account.group,
opening: 0,
debit: 0,
credit: 0,
closing: 0,
accounts: 0
};
row.opening += open;
row.debit += debit;
row.credit += credit;
row.closing += closing;
row.accounts += 1;
byHead.set(account.group, row);
}
return [...byHead.values()];
}
const SUNDRY_CODES = [
'140104',
'230103',
'140199',
'230102'
];
/**
* Outstanding items in the sundry and suspense heads, aged from the posting date.
* These accounts are where unreconciled entries accumulate, so ageing is the point
* of the register.
*/
export function sundryRegister(range: GlDateRange, branchCode: string): SundryItemRow[] {
const wanted = new Set(SUNDRY_CODES);
const end = new Date(`${range.to}T00:00:00Z`).getTime();
const rows: SundryItemRow[] = [];
for (const p of glPostings()) {
if (p.date > range.to || !matchesBranch(p, branchCode) || !wanted.has(p.accountCode)) {
continue;
}
const ageDays = Math.max(0, Math.round((end - new Date(`${p.date}T00:00:00Z`).getTime()) / 86400000));
const bucket =
ageDays <= 30
? 'Up to 30 days'
: ageDays <= 90
? '31 to 90 days'
: ageDays <= 180
? '91 to 180 days'
: 'Over 180 days';
rows.push({
date: p.date,
voucherNo: p.voucherNo,
branchCode: p.branchCode,
code: p.accountCode,
name: glAccount(p.accountCode)?.name ?? p.accountCode,
narration: p.narration,
amount: p.debit || p.credit,
ageDays,
bucket
});
}
return rows.sort((a, b) => b.ageDays - a.ageDays);
}
/**
* Every voucher raised in the period with its debit and credit legs collapsed onto
* one line, plus a per-voucher double-entry check.
*/
export function voucherRegister(range: GlDateRange, branchCode: string): VoucherRow[] {
const grouped = new Map<string, GlPosting[]>();
for (const p of glPostings()) {
if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) {
continue;
}
const list = grouped.get(p.voucherNo);
if (list) {
list.push(p);
} else {
grouped.set(p.voucherNo, [p]);
}
}
const rows: VoucherRow[] = [];
for (const [
voucherNo,
legs
] of grouped) {
const debits = legs.filter((l) => l.debit > 0);
const credits = legs.filter((l) => l.credit > 0);
const drTotal = debits.reduce((s, l) => s + l.debit, 0);
const crTotal = credits.reduce((s, l) => s + l.credit, 0);
const nameOf = (codes: GlPosting[], side: 'debit' | 'credit') =>
[...new Set(codes.map((l) => `${l.accountCode} ${glAccount(l.accountCode)?.name ?? ''}`.trim()))].join(', ');
rows.push({
voucherNo,
date: legs[0].date,
branchCode: legs[0].branchCode,
narration: legs[0].narration,
debitAccounts: nameOf(debits, 'debit') || '—',
creditAccounts: nameOf(credits, 'credit') || '—',
amount: Math.max(drTotal, crTotal),
legs: legs.length,
// Contingent and opening memoranda are single-sided by design.
balanced: voucherNo.startsWith('CNT/') || voucherNo.startsWith('OB/') || Math.round(drTotal - crTotal) === 0
});
}
return rows.sort((a, b) =>
a.date === b.date ? a.voucherNo.localeCompare(b.voucherNo) : a.date.localeCompare(b.date)
);
}
// ── Memoisation ──────────────────────────────────────────────────────────────
export interface GlReportSet {
trialBalance: TrialBalanceRow[];
pnl: { income: PnlRow[]; expense: PnlRow[] };
balanceSheet: { assets: BalanceSheetRow[]; liabilities: BalanceSheetRow[]; surplus: number };
comparative: ComparativeRow[];
contingent: BalanceSheetRow[];
glWise: GlWiseRow[];
miscExpense: MiscExpenseRow[];
}
let cacheKey = '';
let cached: GlReportSet | null = null;
/** All seven reports for one filter selection, computed once per selection. */
export function buildReports(range: GlDateRange, branchCode: string): GlReportSet {
const key = `${range.from}|${range.to}|${branchCode}`;
if (cached && cacheKey === key) {
return cached;
}
cached = {
trialBalance: trialBalance(range, branchCode),
pnl: profitAndLoss(range, branchCode),
balanceSheet: balanceSheet(range, branchCode),
comparative: comparativeBalanceSheet(range, branchCode),
contingent: contingentLiabilities(range, branchCode),
glWise: glWiseDrCr(range, branchCode),
miscExpense: miscExpenseLedger(range, branchCode)
};
cacheKey = key;
return cached;
}
/** Clamp a range to the generated ledger so filters cannot query empty space. */
export function clampToLedger(range: GlDateRange): GlDateRange {
return {
from: range.from < GL_PERIOD.fyStart ? GL_PERIOD.fyStart : range.from,
to: range.to > GL_PERIOD.fyEnd ? GL_PERIOD.fyEnd : range.to
};
}
/**
* Value of a set of GL accounts for a period, used when an RBI return line item is
* mapped to the chart of accounts.
*
* Balance-sheet accounts contribute their closing balance as at the period end;
* income and expenditure accounts contribute their movement within the period.
* That mirrors how the two kinds of figure are read on a return.
*/
export function glValueForCodes(codes: string[], range: GlDateRange, branchCode = ''): number {
if (!codes.length) {
return 0;
}
const wanted = new Set(codes);
const closing = cumulativeBalances(range.to, branchCode);
const movement = periodTurnover(range, branchCode);
let total = 0;
for (const code of wanted) {
const account = glAccount(code);
if (!account) {
continue;
}
if (account.type === 'INCOME' || account.type === 'EXPENSE') {
const move = movement.get(code);
if (!move) {
continue;
}
total += account.type === 'INCOME' ? move.credit - move.debit : move.debit - move.credit;
} else {
total += closing.get(code) ?? 0;
}
}
return total;
}
export function sum<T>(rows: T[], pick: (row: T) => number): number {
return rows.reduce((acc, r) => acc + pick(r), 0);
}
/** Groups rows by their `group` field, preserving chart-of-accounts order. */
export function groupBy<T extends { group: string }>(rows: T[]): { group: string; rows: T[]; total: number }[] {
const order: string[] = [];
const map = new Map<string, T[]>();
for (const r of rows) {
if (!map.has(r.group)) {
map.set(r.group, []);
order.push(r.group);
}
map.get(r.group)!.push(r);
}
return order.map((g) => ({ group: g, rows: map.get(g)!, total: 0 }));
}
+20
View File
@@ -0,0 +1,20 @@
/**
* 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 { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { GlReportsRoutingModule } from './gl-reports-routing.module';
@NgModule({
imports: [
SharedModule,
GlReportsRoutingModule
]
})
export class GlReportsModule {}
@@ -397,6 +397,64 @@
</div>
</ng-container>
<!-- ════════════ CONTINGENT LIABILITIES ════════════ -->
<ng-container *ngIf="activeReport === 'CONTINGENT-LIAB'">
<div class="acr-stats">
<div class="acr-stat-card alert">
<div class="acr-stat-l">Total Contingent Liabilities — {{ bank.fiscalYear }}</div>
<div class="acr-stat-v">&#8377; {{ contingentLiab.current | number: '1.2-2' }} Lakh</div>
<div class="acr-stat-meta">vs {{ contingentLiab.previous | number: '1.2-2' }} Lakh (PY)</div>
</div>
<div class="acr-stat-card" [ngClass]="clVariation <= 0 ? 'good' : 'alert'">
<div class="acr-stat-l">Variation over Previous Year</div>
<div class="acr-stat-v">{{ clVariation >= 0 ? '+' : '' }}{{ clVariation | number: '1.2-2' }} Lakh</div>
<div class="acr-stat-meta">{{ clVariationPct >= 0 ? '+' : '' }}{{ clVariationPct.toFixed(2) }}%</div>
</div>
<div class="acr-stat-card">
<div class="acr-stat-l">As On Date</div>
<div class="acr-stat-v" style="font-size: 16px">{{ bank.asOnDate }}</div>
<div class="acr-stat-meta">Schedule 12</div>
</div>
</div>
<div class="acr-section">
<div class="acr-sec-h">
<h3 class="acr-sec-t">Contingent Liabilities &mdash; As on {{ bank.asOnDate }} (₹ in Lakhs)</h3>
<button mat-stroked-button class="acr-btn-xls" (click)="exportContingentLiabCsv()">
<fa-icon icon="file-csv"></fa-icon>&nbsp; Export CSV
</button>
</div>
<div class="acr-tbl-wrap">
<table class="acr-tbl">
<thead>
<tr>
<th colspan="2" class="acr-sub-h">Items Covered Under This Schedule</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of contingentLiabItems">
<td class="mono">{{ item.code }}</td>
<td>{{ item.label }}</td>
</tr>
</tbody>
<tfoot>
<tr class="row-total expense">
<td colspan="2">
<strong>Total Contingent Liabilities: &#8377; {{ contingentLiab.current | number: '1.2-2' }} Lakh</strong>
</td>
</tr>
</tfoot>
</table>
</div>
<p class="acr-note">
<fa-icon icon="info-circle"></fa-icon>&nbsp; Individual item-wise figures for this schedule are not broken
out here — only the audited total is shown, to avoid presenting a sub-item split that could not be
independently verified against the source statement.
</p>
</div>
</ng-container>
<!-- ════════════ GL DR/CR ════════════ -->
<ng-container *ngIf="activeReport === 'GL-DR-CR'">
<div class="acr-stats">
@@ -318,6 +318,16 @@
border-bottom: 1px dashed var(--acr-g200);
}
.acr-note {
margin: 12px 0 0;
font-size: 12px;
font-style: italic;
color: var(--acr-g500);
display: flex;
align-items: flex-start;
gap: 6px;
}
.acr-btn-xls {
font-size: 12px !important;
height: 34px !important;
@@ -14,7 +14,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
export type AcctReportCode = 'PNL' | 'BALANCE-SHEET' | 'COMPARATIVE-BS' | 'GL-DR-CR' | 'MISC-EXP';
export type AcctReportCode = 'PNL' | 'BALANCE-SHEET' | 'COMPARATIVE-BS' | 'CONTINGENT-LIAB' | 'GL-DR-CR' | 'MISC-EXP';
export interface AcctReportsDialogData {
initialReport?: AcctReportCode;
@@ -47,6 +47,11 @@ interface GlLine {
closing: number;
}
interface ContingentLiabItem {
code: string;
label: string;
}
interface MiscExpRow {
txnDate: string;
branch: string;
@@ -81,7 +86,7 @@ export class AccountingReportsDialogComponent {
activeReport: AcctReportCode;
bank = {
name: 'Bharat Urban Bank Ltd',
name: 'CBSNexora Bank Ltd',
branch: 'Head Office',
asOnDate: '31-03-2026',
period: '01-04-2025 to 31-03-2026',
@@ -94,7 +99,7 @@ export class AccountingReportsDialogComponent {
poDesignation: 'Chief Finance Officer',
auditor: 'M/s Sharma & Associates, Chartered Accountants',
reportRefNo: '157/2',
userCode: 'BUBL01',
userCode: 'CBSN01',
branchCount: 15
};
@@ -120,6 +125,13 @@ export class AccountingReportsDialogComponent {
description: 'Side-by-side comparison of current vs previous fiscal year.',
group: 'Financial Statement'
},
{
code: 'CONTINGENT-LIAB' as AcctReportCode,
name: 'Contingent Liabilities',
icon: 'exclamation-triangle',
description: 'Off-balance-sheet claims, guarantees & contingent exposures (Schedule 12).',
group: 'Financial Statement'
},
{
code: 'GL-DR-CR' as AcctReportCode,
name: 'GL Wise DR/CR',
@@ -136,309 +148,192 @@ export class AccountingReportsDialogComponent {
}
];
// -------------------- P&L Data (₹ in Lakhs) --------------------
// -------------------- P&L Data (₹ in Lakhs) — FY 2025-26 audited figures --------------------
pnl: PnlLine[] = [
{ code: 'I-01', group: 'INCOME', subgroup: 'Interest Earned', label: 'Interest on Advances', amount: 38420 },
{ code: 'I-02', group: 'INCOME', subgroup: 'Interest Earned', label: 'Interest on Investments', amount: 14860 },
{ code: 'I-01', group: 'INCOME', subgroup: 'Interest Earned', label: 'Interest on Advances & Bills', amount: 102641.73 },
{ code: 'I-02', group: 'INCOME', subgroup: 'Interest Earned', label: 'Income on Investments', amount: 41936.75 },
{
code: 'I-03',
group: 'INCOME',
subgroup: 'Interest Earned',
label: 'Interest on Balances with RBI / Other Banks',
amount: 1240
},
{
code: 'I-04',
group: 'INCOME',
subgroup: 'Other Income',
label: 'Commission, Exchange & Brokerage',
amount: 1820
amount: 144.2
},
{ code: 'I-04', group: 'INCOME', subgroup: 'Other Income', label: 'Dividend Income', amount: 4.11 },
{
code: 'I-05',
group: 'INCOME',
subgroup: 'Other Income',
label: 'Net Income on Sale of Fixed Assets',
amount: 0.3
},
{ code: 'I-06', group: 'INCOME', subgroup: 'Other Income', label: 'Other Income (ATM/POS/UPI/Rental/Misc)', amount: 280 },
{
code: 'E-01',
group: 'EXPENDITURE',
subgroup: 'Interest Expended',
label: 'Interest Paid on Deposits & Other Borrowings',
amount: 62861.8
},
{ code: 'I-05', group: 'INCOME', subgroup: 'Other Income', label: 'Profit on Sale of Investments', amount: 480 },
{ code: 'I-06', group: 'INCOME', subgroup: 'Other Income', label: 'Dividend Income', amount: 142 },
{ code: 'I-07', group: 'INCOME', subgroup: 'Other Income', label: 'Miscellaneous Income', amount: 318 },
{ code: 'E-01', group: 'EXPENDITURE', subgroup: 'Interest Expended', label: 'Interest on Deposits', amount: 28640 },
{
code: 'E-02',
group: 'EXPENDITURE',
subgroup: 'Interest Expended',
label: 'Interest on RBI / Inter-Bank Borrowings',
amount: 980
label: 'Interest Paid on NABARD Borrowings',
amount: 47083.71
},
{
code: 'E-03',
group: 'EXPENDITURE',
subgroup: 'Operating Expenses',
label: 'Payments to & Provisions for Employees',
amount: 8420
label: 'Operating Expenses (Staff, Admin, Establishment)',
amount: 14503.79
},
{
code: 'E-04',
group: 'EXPENDITURE',
subgroup: 'Operating Expenses',
label: 'Rent, Taxes & Lighting',
amount: 1240
subgroup: 'Provisions',
label: 'Provision for Standard Assets',
amount: 730
},
{
code: 'E-05',
group: 'EXPENDITURE',
subgroup: 'Provisions',
label: 'Provision for Doubtful & Bad Debts (Loans & Advances)',
amount: 2068.2
},
{ code: 'E-05', group: 'EXPENDITURE', subgroup: 'Operating Expenses', label: 'Printing & Stationery', amount: 184 },
{
code: 'E-06',
group: 'EXPENDITURE',
subgroup: 'Operating Expenses',
label: 'Depreciation on Bank Property',
amount: 642
subgroup: 'Provisions',
label: 'Bad Debts Written Off',
amount: 906.44
},
{
code: 'E-07',
group: 'EXPENDITURE',
subgroup: 'Operating Expenses',
label: 'Auditors Fees & Expenses',
amount: 124
},
{
code: 'E-08',
group: 'EXPENDITURE',
subgroup: 'Operating Expenses',
label: 'Repairs, Maintenance & Insurance',
amount: 386
},
{
code: 'E-09',
group: 'EXPENDITURE',
subgroup: 'Operating Expenses',
label: 'Other Operating Expenses',
amount: 824
},
{
code: 'E-10',
group: 'EXPENDITURE',
subgroup: 'Provisions',
label: 'Provisions for NPA / Standard Assets',
amount: 4280
label: 'Provision for Staff Training Reserve',
amount: 245.06
},
{ code: 'E-11', group: 'EXPENDITURE', subgroup: 'Provisions', label: 'Provisions for Tax', amount: 2120 }
{ code: 'E-08', group: 'EXPENDITURE', subgroup: 'Provisions', label: 'Provision for Income Tax', amount: 4600 }
];
// -------------------- Balance Sheet (₹ in Lakhs) --------------------
// -------------------- Balance Sheet (₹ in Lakhs) — as on 31-03-2026, audited --------------------
bs: BsLine[] = [
{
code: 'L-01',
side: 'CAPITAL-LIAB',
group: 'Capital & Reserves',
label: 'Capital (Paid-up Share Capital)',
amount: 22000,
prevAmount: 20800
amount: 92837.18,
prevAmount: 88842.87
},
{
code: 'L-02',
side: 'CAPITAL-LIAB',
group: 'Capital & Reserves',
label: 'Reserves & Surplus',
amount: 32400,
prevAmount: 28640
amount: 155847.97,
prevAmount: 145524.37
},
{
code: 'L-03',
side: 'CAPITAL-LIAB',
group: 'Deposits',
label: 'Demand Deposits — Current Accounts',
amount: 84200,
prevAmount: 78420
label: 'Deposits',
amount: 1154054.96,
prevAmount: 997239.51
},
{
code: 'L-04',
side: 'CAPITAL-LIAB',
group: 'Deposits',
label: 'Savings Bank Deposits',
amount: 218600,
prevAmount: 198400
group: 'Borrowings',
label: 'Borrowings (NABARD / Other Banks)',
amount: 1113954.48,
prevAmount: 993301.8
},
{
code: 'L-05',
side: 'CAPITAL-LIAB',
group: 'Deposits',
label: 'Term Deposits',
amount: 462800,
prevAmount: 412400
},
{
code: 'L-06',
side: 'CAPITAL-LIAB',
group: 'Borrowings',
label: 'Borrowings from RBI / NABARD',
amount: 14180,
prevAmount: 16240
},
{
code: 'L-07',
side: 'CAPITAL-LIAB',
group: 'Borrowings',
label: 'Other Borrowings',
amount: 10000,
prevAmount: 9420
},
{
code: 'L-08',
side: 'CAPITAL-LIAB',
group: 'Other Liabilities',
label: 'Bills Payable',
amount: 4420,
prevAmount: 3980
},
{
code: 'L-09',
side: 'CAPITAL-LIAB',
group: 'Other Liabilities',
label: 'Inter-Office Adjustments (Net)',
amount: 1820,
prevAmount: 1640
},
{
code: 'L-10',
side: 'CAPITAL-LIAB',
group: 'Other Liabilities',
label: 'Interest Accrued',
amount: 6240,
prevAmount: 5840
},
{
code: 'L-11',
side: 'CAPITAL-LIAB',
group: 'Other Liabilities',
label: 'Other Liabilities & Provisions',
amount: 5940,
prevAmount: 5420
amount: 121037.61,
prevAmount: 124706.3
},
{
code: 'A-01',
side: 'ASSETS',
group: 'Cash & Bank Balances',
label: 'Cash on Hand',
amount: 8420,
prevAmount: 7820
label: 'Cash & Balances with RBI',
amount: 32627.16,
prevAmount: 33361.93
},
{
code: 'A-02',
side: 'ASSETS',
group: 'Cash & Bank Balances',
label: 'Balances with RBI',
amount: 38400,
prevAmount: 34200
group: 'Money at Call',
label: 'Balances with Other Banks & Money at Call/Short Notice',
amount: 604619.45,
prevAmount: 549599.47
},
{
code: 'A-03',
side: 'ASSETS',
group: 'Cash & Bank Balances',
label: 'Balances with Other Banks — Current',
amount: 14800,
prevAmount: 12400
group: 'Investments',
label: 'Investments',
amount: 316953.61,
prevAmount: 248274.61
},
{
code: 'A-04',
side: 'ASSETS',
group: 'Cash & Bank Balances',
label: 'Balances with Other Banks — Term Deposits',
amount: 10000,
prevAmount: 9200
group: 'Advances',
label: 'Loans & Advances',
amount: 1627711.4,
prevAmount: 1466401.79
},
{
code: 'A-05',
side: 'ASSETS',
group: 'Money at Call',
label: 'Money at Call & Short Notice',
amount: 12400,
prevAmount: 10800
group: 'Fixed Assets',
label: 'Fixed Assets (Property, Plant & Equipment)',
amount: 33791.75,
prevAmount: 33727.66
},
{
code: 'A-06',
side: 'ASSETS',
group: 'Investments',
label: 'Government Securities (SLR)',
amount: 142400,
prevAmount: 128600
},
{
code: 'A-07',
side: 'ASSETS',
group: 'Investments',
label: 'Other Approved Securities',
amount: 28200,
prevAmount: 24800
},
{
code: 'A-08',
side: 'ASSETS',
group: 'Investments',
label: 'Shares / Debentures / Bonds',
amount: 14000,
prevAmount: 12200
},
{
code: 'A-09',
side: 'ASSETS',
group: 'Advances',
label: 'Cash Credit, Overdraft & Demand Loans',
amount: 168400,
prevAmount: 152800
},
{
code: 'A-10',
side: 'ASSETS',
group: 'Advances',
label: 'Term Loans (Secured)',
amount: 217865,
prevAmount: 198400
},
{
code: 'A-11',
side: 'ASSETS',
group: 'Advances',
label: 'Term Loans (Unsecured)',
amount: 30000,
prevAmount: 27200
},
{
code: 'A-12',
side: 'ASSETS',
group: 'Fixed Assets',
label: 'Premises (Net of Depreciation)',
amount: 9420,
prevAmount: 8820
},
{
code: 'A-13',
side: 'ASSETS',
group: 'Fixed Assets',
label: 'Other Fixed Assets',
amount: 4780,
prevAmount: 4620
},
{
code: 'A-14',
side: 'ASSETS',
group: 'Other Assets',
label: 'Inter-Office Adjustments (Net)',
amount: 0,
prevAmount: 0
},
{
code: 'A-15',
side: 'ASSETS',
group: 'Other Assets',
label: 'Tax Paid in Advance & TDS',
amount: 5420,
prevAmount: 4820
},
{ code: 'A-16', side: 'ASSETS', group: 'Other Assets', label: 'Stationery & Stamps', amount: 184, prevAmount: 142 },
{
code: 'A-17',
side: 'ASSETS',
group: 'Other Assets',
label: 'Sundry Debtors & Other Assets',
amount: 2876,
prevAmount: 2540
label: 'Other Assets',
amount: 22028.83,
prevAmount: 18249.4
}
];
// -------------------- Contingent Liabilities (Schedule 12, ₹ in Lakhs) --------------------
// Only the audited TOTAL is reported here — the schedule's individual line items (claims not
// acknowledged as debts, letter of guarantees, DEAF transfers, gratuity/RBI-penalty/GST disputes)
// could not be re-verified against the source figures with confidence, so they are omitted
// rather than risk showing an unverified breakdown.
contingentLiabItems: ContingentLiabItem[] = [
{ code: 'I', label: 'Claims against the bank not acknowledged as debts' },
{ code: 'II', label: 'Letter of Guarantees' },
{ code: 'III', label: 'Amounts transferred to Depositor Education and Awareness Fund (DEAF)' },
{ code: 'IV', label: 'Other items for which the bank is contingently liable (Gratuity / RBI Penalty / GST Demand)' }
];
contingentLiab = {
current: 1633.3,
previous: 1522.13
};
get clVariation(): number {
return this.variation(this.contingentLiab.current, this.contingentLiab.previous);
}
get clVariationPct(): number {
return this.variationPct(this.contingentLiab.current, this.contingentLiab.previous);
}
// -------------------- GL Wise DR/CR Data --------------------
glReport: GlLine[] = [
{
@@ -1008,6 +903,7 @@ export class AccountingReportsDialogComponent {
if (this.activeReport === 'PNL') return this.exportPnlCsv();
if (this.activeReport === 'BALANCE-SHEET') return this.exportBsCsv(false);
if (this.activeReport === 'COMPARATIVE-BS') return this.exportBsCsv(true);
if (this.activeReport === 'CONTINGENT-LIAB') return this.exportContingentLiabCsv();
if (this.activeReport === 'GL-DR-CR') return this.exportGlCsv();
if (this.activeReport === 'MISC-EXP') return this.exportMiscCsv();
}
@@ -1155,6 +1051,49 @@ export class AccountingReportsDialogComponent {
this.saveCsv(rows, `${comparative ? 'Comparative-' : ''}Balance-Sheet-${this.dateTag()}.csv`);
}
exportContingentLiabCsv(): void {
const rows: string[][] = [];
rows.push([
this.bank.name,
this.bank.branch
]);
rows.push([
'CONTINGENT LIABILITIES (SCHEDULE 12)',
`As on ${this.bank.asOnDate}`
]);
rows.push([]);
rows.push(['Items covered:']);
for (const item of this.contingentLiabItems) {
rows.push([
item.code,
item.label
]);
}
rows.push([]);
rows.push([
'',
'TOTAL CONTINGENT LIABILITIES',
`FY ${this.bank.fiscalYear} (₹ Lakh)`,
`FY ${this.bank.prevFiscalYear} (₹ Lakh)`,
'Variation',
'Variation %'
]);
rows.push([
'',
'',
this.contingentLiab.current.toFixed(2),
this.contingentLiab.previous.toFixed(2),
this.clVariation.toFixed(2),
this.clVariationPct.toFixed(2) + '%'
]);
rows.push([]);
rows.push([
'Certified by',
this.bank.principalOfficer + ' — ' + this.bank.poDesignation
]);
this.saveCsv(rows, `Contingent-Liabilities-${this.dateTag()}.csv`);
}
exportGlCsv(): void {
const rows: string[][] = [];
rows.push([
@@ -54,7 +54,7 @@ export class AlmReportDialogComponent {
activeTab: 'dashboard' | 'ladder' | 'monthly' | 'returns' = 'dashboard';
bank = {
name: 'Bharat Urban Bank Ltd',
name: 'CBSNexora Bank Ltd',
branch: 'Head Office',
asOnDate: '31-03-2026',
quarter: 'Q4 FY 2025-26',
@@ -63,7 +63,7 @@ export class AlmReportDialogComponent {
principalOfficer: 'Karthik Kamath',
formCode: 'Form ALM-1',
reportRefNo: '1036',
userCode: 'BUBL01',
userCode: 'CBSN01',
/** Net Demand & Time Liabilities — denominator for ratios */
ndtl: 740000, // ₹7,400 Cr in Lakhs
/** RBI tolerance: cumulative negative gap up to 14 days ≤ 5%, 28 days ≤ 10% */
@@ -14,7 +14,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
export type CimsFormCode = 'OSS-2' | 'PSA' | 'RAL' | 'RCP-UCB';
export type CimsFormCode = 'OSS-2' | 'PSA' | 'RAL' | 'RCP-UCB' | 'OSS-4' | 'OSS-5' | 'OSS-6' | 'OSS-7';
export interface CimsXbrlDialogData {
initialForm?: CimsFormCode;
@@ -64,20 +64,20 @@ export class CimsXbrlDialogComponent {
activeTab: 'filing' | 'data' | 'preview' = 'filing';
bank = {
name: 'Bharat Urban Bank Ltd',
name: 'CBSNexora Bank Ltd',
branch: 'Head Office',
bsrCode: 'BUBL0000123',
ifsc: 'BUBL0000123',
legalEntityId: 'BUBL-LEI-2025',
bsrCode: 'CBSN0000123',
ifsc: 'CBSN0000123',
legalEntityId: 'CBSN-LEI-2025',
pan: 'AAACB1234L',
asOnDate: '31-03-2026',
quarter: 'Q4 FY 2025-26',
fiscalYear: '2025-26',
principalOfficer: 'Karthik Kamath',
poDesignation: 'Chief Compliance Officer',
poEmail: 'compliance@bharaturbanbank.in',
poEmail: 'compliance@cbsnexorabank.in',
poMobile: '+91-9988002211',
cimsUserId: 'BUBL_CIMS_001',
cimsUserId: 'CBSN_CIMS_001',
cimsPortal: 'https://cims.rbi.org.in/'
};
@@ -97,7 +97,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:CapAdq.CRAR',
section: 'Capital Adequacy',
label: 'CRAR (Total)',
value: 14.82,
value: 21.82,
unit: 'Percent',
type: 'percent'
},
@@ -105,7 +105,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:CapAdq.Tier1',
section: 'Capital Adequacy',
label: 'Tier-I CRAR',
value: 12.18,
value: 20.51,
unit: 'Percent',
type: 'percent'
},
@@ -113,7 +113,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:CapAdq.Tier2',
section: 'Capital Adequacy',
label: 'Tier-II CRAR',
value: 2.64,
value: 1.31,
unit: 'Percent',
type: 'percent'
},
@@ -121,7 +121,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.Total',
section: 'Asset Classification',
label: 'Total Advances (₹ Lakh)',
value: 416265,
value: 4112.04,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -129,7 +129,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.Standard',
section: 'Asset Classification',
label: 'Standard Assets (₹ Lakh)',
value: 401820,
value: 3845.88,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -137,7 +137,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.SubStd',
section: 'Asset Classification',
label: 'Sub-Standard (₹ Lakh)',
value: 6220,
value: 102.41,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -145,7 +145,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.Doubtful',
section: 'Asset Classification',
label: 'Doubtful (₹ Lakh)',
value: 5840,
value: 163.75,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -153,7 +153,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.Loss',
section: 'Asset Classification',
label: 'Loss (₹ Lakh)',
value: 2385,
value: 0,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -161,7 +161,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.GNPA',
section: 'NPA Position',
label: 'Gross NPA (₹ Lakh)',
value: 14445,
value: 266.16,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -169,7 +169,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.NNPA',
section: 'NPA Position',
label: 'Net NPA (₹ Lakh)',
value: 5212,
value: -96.33,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -177,7 +177,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.GNPAPct',
section: 'NPA Position',
label: 'GNPA %',
value: 3.47,
value: 6.47,
unit: 'Percent',
type: 'percent'
},
@@ -185,7 +185,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.NNPAPct',
section: 'NPA Position',
label: 'NNPA %',
value: 1.27,
value: -2.6,
unit: 'Percent',
type: 'percent'
},
@@ -193,7 +193,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.PCR',
section: 'NPA Position',
label: 'Provision Coverage Ratio',
value: 64.13,
value: 136.24,
unit: 'Percent',
type: 'percent'
},
@@ -201,7 +201,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Profit.NIM',
section: 'Profitability',
label: 'Net Interest Margin',
value: 3.21,
value: 0.51,
unit: 'Percent',
type: 'percent'
},
@@ -209,7 +209,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Profit.ROA',
section: 'Profitability',
label: 'Return on Assets',
value: 0.92,
value: 0.67,
unit: 'Percent',
type: 'percent'
},
@@ -217,7 +217,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Profit.ROE',
section: 'Profitability',
label: 'Return on Equity',
value: 11.48,
value: 5.38,
unit: 'Percent',
type: 'percent'
},
@@ -254,7 +254,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:Reference.ANBC',
section: 'Reference',
label: 'Adjusted Net Bank Credit (₹ Lakh)',
value: 600000,
value: 3274.83,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -262,7 +262,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:Reference.OBE',
section: 'Reference',
label: 'Credit Equivalent of OBE (₹ Lakh)',
value: 25000,
value: 0,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -270,7 +270,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.Total',
section: 'PSL Achievement',
label: 'Total PSL Outstanding (₹ Lakh)',
value: 241700,
value: 2485.67,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -278,7 +278,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.TotalPct',
section: 'PSL Achievement',
label: 'Total PSL % of ANBC',
value: 40.28,
value: 75.9,
unit: 'Percent',
type: 'percent'
},
@@ -286,7 +286,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.Agri',
section: 'Sub-targets',
label: 'Agriculture (₹ Lakh)',
value: 124000,
value: 39.0,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -294,7 +294,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.AgriPct',
section: 'Sub-targets',
label: 'Agriculture % of ANBC',
value: 20.67,
value: 1.19,
unit: 'Percent',
type: 'percent'
},
@@ -302,7 +302,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.SMF',
section: 'Sub-targets',
label: 'Small &amp; Marginal Farmers (₹ Lakh)',
value: 72000,
value: 0,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -310,23 +310,23 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.SMFPct',
section: 'Sub-targets',
label: 'SMF % of ANBC',
value: 12.0,
value: 0,
unit: 'Percent',
type: 'percent'
},
{
ref: 'psa:PSL.MicroEnt',
section: 'Sub-targets',
label: 'Micro Enterprises (₹ Lakh)',
value: 51000,
label: 'MSME Enterprises (₹ Lakh)',
value: 2313.83,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'psa:PSL.MicroPct',
section: 'Sub-targets',
label: 'Micro Enterprises % of ANBC',
value: 8.5,
label: 'MSME Enterprises % of ANBC',
value: 70.65,
unit: 'Percent',
type: 'percent'
},
@@ -334,7 +334,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.Weaker',
section: 'Sub-targets',
label: 'Weaker Sections (₹ Lakh)',
value: 85500,
value: 990.28,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -342,7 +342,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.WeakerPct',
section: 'Sub-targets',
label: 'Weaker Sections % of ANBC',
value: 14.25,
value: 30.22,
unit: 'Percent',
type: 'percent'
},
@@ -350,7 +350,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.Housing',
section: 'Other Categories',
label: 'Housing (₹ Lakh)',
value: 9200,
value: 115.12,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -358,7 +358,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.Education',
section: 'Other Categories',
label: 'Education (₹ Lakh)',
value: 4200,
value: 17.32,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -366,7 +366,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.Export',
section: 'Other Categories',
label: 'Export Credit (₹ Lakh)',
value: 3800,
value: 0,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -374,7 +374,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.RenEnergy',
section: 'Other Categories',
label: 'Renewable Energy (₹ Lakh)',
value: 2400,
value: 0,
unit: 'INR-Lakh',
type: 'monetary'
}
@@ -395,15 +395,15 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Liab.Capital',
section: 'Liabilities',
label: 'Capital (₹ Lakh)',
value: 22000,
value: 272.78,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'ral:Liab.Reserves',
section: 'Liabilities',
label: 'Reserves &amp; Surplus (₹ Lakh)',
value: 32400,
label: 'Reserves, Surplus &amp; Profit (₹ Lakh)',
value: 712.6,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -411,7 +411,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Liab.DepDemand',
section: 'Liabilities',
label: 'Demand Deposits (₹ Lakh)',
value: 84200,
value: 195.92,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -419,7 +419,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Liab.DepSavings',
section: 'Liabilities',
label: 'Savings Deposits (₹ Lakh)',
value: 218600,
value: 2081.16,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -427,7 +427,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Liab.DepTerm',
section: 'Liabilities',
label: 'Term Deposits (₹ Lakh)',
value: 462800,
value: 4066.01,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -435,7 +435,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Liab.Borrow',
section: 'Liabilities',
label: 'Borrowings (₹ Lakh)',
value: 24180,
value: 0,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -443,7 +443,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Liab.Other',
section: 'Liabilities',
label: 'Other Liabilities &amp; Provisions (₹ Lakh)',
value: 18420,
value: 780.73,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -451,7 +451,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Asset.Cash',
section: 'Assets',
label: 'Cash on Hand (₹ Lakh)',
value: 8420,
value: 99.96,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -459,15 +459,15 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Asset.RBI',
section: 'Assets',
label: 'Balances with RBI (₹ Lakh)',
value: 38400,
value: 0,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'ral:Asset.Banks',
section: 'Assets',
label: 'Balances with Banks (₹ Lakh)',
value: 24800,
label: 'Balances with Banks &amp; Other Institutions (₹ Lakh)',
value: 1349.53,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -475,7 +475,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Asset.Money',
section: 'Assets',
label: 'Money at Call &amp; Short Notice (₹ Lakh)',
value: 12400,
value: 0,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -483,7 +483,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Asset.Invest',
section: 'Assets',
label: 'Investments (₹ Lakh)',
value: 184600,
value: 2342.97,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -491,7 +491,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Asset.Adv',
section: 'Assets',
label: 'Advances (₹ Lakh)',
value: 416265,
value: 3912.88,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -499,23 +499,23 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Asset.Fixed',
section: 'Assets',
label: 'Fixed Assets (₹ Lakh)',
value: 14200,
value: 49.48,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'ral:Asset.Other',
section: 'Assets',
label: 'Other Assets (₹ Lakh)',
value: 12480,
label: 'Other Assets &amp; Interest Receivable (₹ Lakh)',
value: 354.38,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'ral:OBE.Guar',
section: 'Off-Balance Sheet',
label: 'Bank Guarantees (₹ Lakh)',
value: 18420,
label: 'Contingent Liabilities (₹ Lakh)',
value: 26.38,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -523,7 +523,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:OBE.LC',
section: 'Off-Balance Sheet',
label: 'Letters of Credit (₹ Lakh)',
value: 6580,
value: 0,
unit: 'INR-Lakh',
type: 'monetary'
}
@@ -653,6 +653,293 @@ export class CimsXbrlDialogComponent {
type: 'monetary'
}
]
},
'OSS-4': {
code: 'OSS-4',
name: 'OSS-4',
fullName: 'Connected Lending &amp; Large Exposure Statement',
description:
'Quarterly statement of loans to directors/relatives, senior officers, and exposures exceeding prudential thresholds. Illustrative figures — no source filing was provided for this return; values are modelled on this bank\'s own capital/advances scale for consistency.',
cimsCode: 'CIMS_OSS_004',
taxonomy: 'rbi-cims-oss4-2024-04-01',
periodicity: 'Quarterly',
lastFiled: '15-01-2026',
nextDue: '15-04-2026',
fields: [
{
ref: 'oss4:Connected.Directors',
section: 'Connected Lending',
label: 'Loans to Directors &amp; Relatives (₹ Lakh)',
value: 42.5,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'oss4:Connected.SrOfficers',
section: 'Connected Lending',
label: 'Loans to Senior Officers (₹ Lakh)',
value: 18.75,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'oss4:Connected.Accounts',
section: 'Connected Lending',
label: 'Number of Connected Lending Accounts',
value: 12,
unit: 'Number',
type: 'numeric'
},
{
ref: 'oss4:LargeExp.Amount',
section: 'Large Exposures',
label: 'Exposures &gt; 10% of Capital Funds (₹ Lakh)',
value: 285.0,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'oss4:LargeExp.Pct',
section: 'Large Exposures',
label: 'Large Exposures as % of Capital Funds',
value: 30.58,
unit: 'Percent',
type: 'percent'
},
{
ref: 'oss4:LargeExp.SingleBreach',
section: 'Large Exposures',
label: 'Single Borrower Limit Breaches',
value: 0,
unit: 'Number',
type: 'numeric'
},
{
ref: 'oss4:LargeExp.GroupBreach',
section: 'Large Exposures',
label: 'Group Borrower Limit Breaches',
value: 0,
unit: 'Number',
type: 'numeric'
}
]
},
'OSS-5': {
code: 'OSS-5',
name: 'OSS-5',
fullName: 'Institutional &amp; Membership Profile',
description:
'Annual statement of branch network, staff strength, membership and depositor/borrower base. Illustrative figures — no source filing was provided for this return; membership share capital is aligned to this bank\'s real paid-up capital figure.',
cimsCode: 'CIMS_OSS_005',
taxonomy: 'rbi-cims-oss5-2024-04-01',
periodicity: 'Annual',
lastFiled: '30-04-2025',
nextDue: '30-04-2026',
fields: [
{
ref: 'oss5:Profile.Branches',
section: 'Network',
label: 'Number of Branches',
value: 8,
unit: 'Number',
type: 'numeric'
},
{
ref: 'oss5:Profile.Employees',
section: 'Network',
label: 'Number of Employees',
value: 64,
unit: 'Number',
type: 'numeric'
},
{
ref: 'oss5:Profile.AreaOfOperation',
section: 'Network',
label: 'Area of Operation',
value: 'Single District',
type: 'string'
},
{
ref: 'oss5:Profile.Members',
section: 'Membership',
label: 'Number of Regular Members',
value: 18450,
unit: 'Number',
type: 'numeric'
},
{
ref: 'oss5:Profile.ShareCapital',
section: 'Membership',
label: 'Membership Share Capital (₹ Lakh)',
value: 272.78,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'oss5:Profile.Depositors',
section: 'Customer Base',
label: 'Number of Depositors',
value: 42180,
unit: 'Number',
type: 'numeric'
},
{
ref: 'oss5:Profile.Borrowers',
section: 'Customer Base',
label: 'Number of Borrowers',
value: 3860,
unit: 'Number',
type: 'numeric'
}
]
},
'OSS-6': {
code: 'OSS-6',
name: 'OSS-6',
fullName: 'Structural Liquidity &amp; Interest Rate Sensitivity Statement',
description:
'Fortnightly maturity-bucket gap analysis of rate-sensitive assets and liabilities. Illustrative figures — no source filing was provided for this return.',
cimsCode: 'CIMS_OSS_006',
taxonomy: 'rbi-cims-oss6-2024-04-01',
periodicity: 'Monthly',
lastFiled: '14-04-2026',
nextDue: '28-04-2026',
fields: [
{
ref: 'oss6:Gap.1to14Days',
section: 'Maturity Gap (₹ Lakh)',
label: '1-14 Days',
value: -45.2,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'oss6:Gap.15to28Days',
section: 'Maturity Gap (₹ Lakh)',
label: '15-28 Days',
value: -12.8,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'oss6:Gap.29DaysTo3M',
section: 'Maturity Gap (₹ Lakh)',
label: '29 Days to 3 Months',
value: 88.4,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'oss6:Gap.Over3To6M',
section: 'Maturity Gap (₹ Lakh)',
label: 'Over 3 Months to 6 Months',
value: 62.15,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'oss6:Gap.Over6MTo1Y',
section: 'Maturity Gap (₹ Lakh)',
label: 'Over 6 Months to 1 Year',
value: -28.9,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'oss6:Gap.Over1To3Y',
section: 'Maturity Gap (₹ Lakh)',
label: 'Over 1 Year to 3 Years',
value: 210.6,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'oss6:Gap.Over3To5Y',
section: 'Maturity Gap (₹ Lakh)',
label: 'Over 3 Years to 5 Years',
value: 95.3,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'oss6:Gap.Over5Y',
section: 'Maturity Gap (₹ Lakh)',
label: 'Over 5 Years',
value: -18.75,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'oss6:Liq.CumNegGapPct',
section: 'Liquidity Ratios',
label: 'Cumulative Negative Gap (14-Day) as % of Cumulative Outflows',
value: 4.85,
unit: 'Percent',
type: 'percent'
}
]
},
'OSS-7': {
code: 'OSS-7',
name: 'OSS-7',
fullName: 'Statement of Frauds &amp; Customer Complaints',
description:
'Quarterly statement of frauds reported and customer-complaint disposal. Illustrative figures — no source filing was provided for this return.',
cimsCode: 'CIMS_OSS_007',
taxonomy: 'rbi-cims-oss7-2024-04-01',
periodicity: 'Quarterly',
lastFiled: '15-01-2026',
nextDue: '15-04-2026',
fields: [
{
ref: 'oss7:Fraud.Count',
section: 'Frauds',
label: 'Number of Frauds Reported',
value: 1,
unit: 'Number',
type: 'numeric'
},
{
ref: 'oss7:Fraud.Amount',
section: 'Frauds',
label: 'Amount Involved in Frauds (₹ Lakh)',
value: 2.4,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'oss7:Complaints.Received',
section: 'Customer Complaints',
label: 'Complaints Received (Quarter)',
value: 14,
unit: 'Number',
type: 'numeric'
},
{
ref: 'oss7:Complaints.Resolved',
section: 'Customer Complaints',
label: 'Complaints Resolved',
value: 12,
unit: 'Number',
type: 'numeric'
},
{
ref: 'oss7:Complaints.Pending',
section: 'Customer Complaints',
label: 'Complaints Pending',
value: 2,
unit: 'Number',
type: 'numeric'
},
{
ref: 'oss7:Complaints.PendingOver30',
section: 'Customer Complaints',
label: 'Complaints Pending &gt; 30 Days',
value: 0,
unit: 'Number',
type: 'numeric'
}
]
}
};
@@ -660,7 +947,11 @@ export class CimsXbrlDialogComponent {
'OSS-2',
'PSA',
'RAL',
'RCP-UCB'
'RCP-UCB',
'OSS-4',
'OSS-5',
'OSS-6',
'OSS-7'
];
data = inject<CimsXbrlDialogData | null>(MAT_DIALOG_DATA, { optional: true });
@@ -76,7 +76,7 @@ export class CkycReportsDialogComponent {
activeReport: CkycReportCode;
bank = {
name: 'Bharat Urban Bank Ltd',
name: 'CBSNexora Bank Ltd',
branch: 'Head Office',
asOnDate: '29-01-2026',
fiscalYear: '2025-26',
@@ -84,7 +84,7 @@ export class CkycReportsDialogComponent {
principalOfficer: 'Karthik Kamath',
poDesignation: 'Principal Compliance Officer',
reportRefNo: '2075/1',
userCode: 'BUBL01',
userCode: 'CBSN01',
fyStart: '01-04-2025',
fyEnd: '31-03-2026'
};
@@ -121,7 +121,7 @@ export class CkycReportsDialogComponent {
rekyc: RekycRow[] = [
{
ucode: 'BUBL000201',
ucode: 'CBSN000201',
name: 'Ravi Kumar Sharma',
customerType: 'INDIVIDUAL',
risk: 'LOW',
@@ -132,7 +132,7 @@ export class CkycReportsDialogComponent {
segment: 'BELOW-25-LACS'
},
{
ucode: 'BUBL000418',
ucode: 'CBSN000418',
name: 'Priya Patel',
customerType: 'INDIVIDUAL',
risk: 'MEDIUM',
@@ -143,7 +143,7 @@ export class CkycReportsDialogComponent {
segment: 'ABOVE-25-LACS'
},
{
ucode: 'BUBL000522',
ucode: 'CBSN000522',
name: 'Arjun Deshmukh',
customerType: 'INDIVIDUAL',
risk: 'LOW',
@@ -154,7 +154,7 @@ export class CkycReportsDialogComponent {
segment: 'BELOW-25-LACS'
},
{
ucode: 'BUBL000711',
ucode: 'CBSN000711',
name: 'Vikram Reddy',
customerType: 'INDIVIDUAL',
risk: 'HIGH',
@@ -165,7 +165,7 @@ export class CkycReportsDialogComponent {
segment: 'ABOVE-25-LACS'
},
{
ucode: 'BUBL000814',
ucode: 'CBSN000814',
name: 'Lakshmi Iyer',
customerType: 'INDIVIDUAL',
risk: 'LOW',
@@ -176,7 +176,7 @@ export class CkycReportsDialogComponent {
segment: 'BELOW-25-LACS'
},
{
ucode: 'BUBL001012',
ucode: 'CBSN001012',
name: 'Nirav Shah',
customerType: 'INDIVIDUAL',
risk: 'MEDIUM',
@@ -187,7 +187,7 @@ export class CkycReportsDialogComponent {
segment: 'BELOW-25-LACS'
},
{
ucode: 'BUBL001145',
ucode: 'CBSN001145',
name: 'Kavita Verma',
customerType: 'INDIVIDUAL',
risk: 'HIGH',
@@ -198,7 +198,7 @@ export class CkycReportsDialogComponent {
segment: 'ABOVE-25-LACS'
},
{
ucode: 'BUBL001458',
ucode: 'CBSN001458',
name: 'Sameer Kapoor',
customerType: 'INDIVIDUAL',
risk: 'MEDIUM',
@@ -209,7 +209,7 @@ export class CkycReportsDialogComponent {
segment: 'ABOVE-25-LACS'
},
{
ucode: 'BUBL001721',
ucode: 'CBSN001721',
name: 'Karthik Subramanian',
customerType: 'INDIVIDUAL',
risk: 'LOW',
@@ -220,7 +220,7 @@ export class CkycReportsDialogComponent {
segment: 'BELOW-25-LACS'
},
{
ucode: 'BUBL002012',
ucode: 'CBSN002012',
name: 'Abhishek Banerjee',
customerType: 'INDIVIDUAL',
risk: 'MEDIUM',
@@ -231,7 +231,7 @@ export class CkycReportsDialogComponent {
segment: 'ABOVE-25-LACS'
},
{
ucode: 'BUBL002314',
ucode: 'CBSN002314',
name: 'Gaurav Thakur',
customerType: 'INDIVIDUAL',
risk: 'LOW',
@@ -242,7 +242,7 @@ export class CkycReportsDialogComponent {
segment: 'BELOW-25-LACS'
},
{
ucode: 'BUBL002615',
ucode: 'CBSN002615',
name: 'Manmohan Singh Gill',
customerType: 'INDIVIDUAL',
risk: 'HIGH',
@@ -253,7 +253,7 @@ export class CkycReportsDialogComponent {
segment: 'BELOW-25-LACS'
},
{
ucode: 'BUBL002918',
ucode: 'CBSN002918',
name: 'Sunita Devi',
customerType: 'INDIVIDUAL',
risk: 'LOW',
@@ -264,7 +264,7 @@ export class CkycReportsDialogComponent {
segment: 'BELOW-25-LACS'
},
{
ucode: 'BUBL003221',
ucode: 'CBSN003221',
name: 'Deepak Tiwari',
customerType: 'INDIVIDUAL',
risk: 'MEDIUM',
@@ -275,7 +275,7 @@ export class CkycReportsDialogComponent {
segment: 'ABOVE-25-LACS'
},
{
ucode: 'BUBL003524',
ucode: 'CBSN003524',
name: 'Harpreet Kaur',
customerType: 'INDIVIDUAL',
risk: 'MEDIUM',
@@ -286,7 +286,7 @@ export class CkycReportsDialogComponent {
segment: 'ABOVE-25-LACS'
},
{
ucode: 'BUBL003812',
ucode: 'CBSN003812',
name: 'Saraswati Mahila Co-op Society',
customerType: 'SOCIETY',
risk: 'MEDIUM',
@@ -297,7 +297,7 @@ export class CkycReportsDialogComponent {
segment: 'ABOVE-25-LACS'
},
{
ucode: 'BUBL004115',
ucode: 'CBSN004115',
name: 'Aditya Enterprises',
customerType: 'FIRM',
risk: 'HIGH',
@@ -308,7 +308,7 @@ export class CkycReportsDialogComponent {
segment: 'ABOVE-25-LACS'
},
{
ucode: 'BUBL004318',
ucode: 'CBSN004318',
name: 'Vasudha Trading HUF',
customerType: 'HUF',
risk: 'MEDIUM',
@@ -332,7 +332,7 @@ export class CkycReportsDialogComponent {
branchCode: '01',
branch: 'Mumbai Main',
acType: 'SB',
clientId: 'BUBL000201',
clientId: 'CBSN000201',
customerName: 'Ravi Kumar Sharma',
oldRisk: 'LOW',
newRisk: 'LOW',
@@ -345,7 +345,7 @@ export class CkycReportsDialogComponent {
branchCode: '02',
branch: 'Pune FC Road',
acType: 'CA',
clientId: 'BUBL000418',
clientId: 'CBSN000418',
customerName: 'Priya Patel',
oldRisk: 'LOW',
newRisk: 'MEDIUM',
@@ -358,7 +358,7 @@ export class CkycReportsDialogComponent {
branchCode: '03',
branch: 'Nagpur Dharampeth',
acType: 'TL',
clientId: 'BUBL000522',
clientId: 'CBSN000522',
customerName: 'Arjun Deshmukh',
oldRisk: 'MEDIUM',
newRisk: 'LOW',
@@ -371,7 +371,7 @@ export class CkycReportsDialogComponent {
branchCode: '04',
branch: 'Bengaluru Jayanagar',
acType: 'OD',
clientId: 'BUBL000711',
clientId: 'CBSN000711',
customerName: 'Vikram Reddy',
oldRisk: 'MEDIUM',
newRisk: 'HIGH',
@@ -384,7 +384,7 @@ export class CkycReportsDialogComponent {
branchCode: '05',
branch: 'Bengaluru Indiranagar',
acType: 'SB',
clientId: 'BUBL000814',
clientId: 'CBSN000814',
customerName: 'Lakshmi Iyer',
oldRisk: 'LOW',
newRisk: 'LOW',
@@ -397,7 +397,7 @@ export class CkycReportsDialogComponent {
branchCode: '06',
branch: 'Ahmedabad Satellite',
acType: 'CC',
clientId: 'BUBL001012',
clientId: 'CBSN001012',
customerName: 'Nirav Shah',
oldRisk: 'LOW',
newRisk: 'MEDIUM',
@@ -410,7 +410,7 @@ export class CkycReportsDialogComponent {
branchCode: '07',
branch: 'Lucknow Gomti Nagar',
acType: 'CA',
clientId: 'BUBL001145',
clientId: 'CBSN001145',
customerName: 'Kavita Verma',
oldRisk: 'MEDIUM',
newRisk: 'HIGH',
@@ -423,7 +423,7 @@ export class CkycReportsDialogComponent {
branchCode: '08',
branch: 'Delhi Greater Kailash',
acType: 'OD',
clientId: 'BUBL001458',
clientId: 'CBSN001458',
customerName: 'Sameer Kapoor',
oldRisk: 'MEDIUM',
newRisk: 'MEDIUM',
@@ -436,7 +436,7 @@ export class CkycReportsDialogComponent {
branchCode: '09',
branch: 'Chennai Valasaravakkam',
acType: 'SB',
clientId: 'BUBL001721',
clientId: 'CBSN001721',
customerName: 'Karthik Subramanian',
oldRisk: 'LOW',
newRisk: 'LOW',
@@ -449,7 +449,7 @@ export class CkycReportsDialogComponent {
branchCode: '10',
branch: 'Kolkata Salt Lake',
acType: 'TL',
clientId: 'BUBL002012',
clientId: 'CBSN002012',
customerName: 'Abhishek Banerjee',
oldRisk: 'LOW',
newRisk: 'MEDIUM',
@@ -462,7 +462,7 @@ export class CkycReportsDialogComponent {
branchCode: '11',
branch: 'Shimla Sector 4',
acType: 'SB',
clientId: 'BUBL002314',
clientId: 'CBSN002314',
customerName: 'Gaurav Thakur',
oldRisk: 'LOW',
newRisk: 'LOW',
@@ -475,7 +475,7 @@ export class CkycReportsDialogComponent {
branchCode: '12',
branch: 'Amritsar Model Town',
acType: 'CC',
clientId: 'BUBL002615',
clientId: 'CBSN002615',
customerName: 'Manmohan Singh Gill',
oldRisk: 'HIGH',
newRisk: 'HIGH',
@@ -488,7 +488,7 @@ export class CkycReportsDialogComponent {
branchCode: '13',
branch: 'Bhopal Arera Colony',
acType: 'SB',
clientId: 'BUBL002918',
clientId: 'CBSN002918',
customerName: 'Sunita Devi',
oldRisk: 'LOW',
newRisk: 'LOW',
@@ -501,7 +501,7 @@ export class CkycReportsDialogComponent {
branchCode: '14',
branch: 'Indore Vijay Nagar',
acType: 'OD',
clientId: 'BUBL003221',
clientId: 'CBSN003221',
customerName: 'Deepak Tiwari',
oldRisk: 'MEDIUM',
newRisk: 'HIGH',
@@ -514,7 +514,7 @@ export class CkycReportsDialogComponent {
branchCode: '15',
branch: 'Ludhiana Sarabha Nagar',
acType: 'TL',
clientId: 'BUBL003524',
clientId: 'CBSN003524',
customerName: 'Harpreet Kaur',
oldRisk: 'MEDIUM',
newRisk: 'MEDIUM',
@@ -165,12 +165,12 @@
<div class="da-compare">
<div class="da-item">
<div class="da-label">Total Deposits</div>
<div class="da-value deposit">&#8377; 3,264.42 Cr</div>
<div class="da-bar"><div class="da-fill deposit-fill" style="width: 78%"></div></div>
<div class="da-value deposit">&#8377; 11,540.55 Cr</div>
<div class="da-bar"><div class="da-fill deposit-fill" style="width: 71%"></div></div>
</div>
<div class="da-item">
<div class="da-label">Total Advances</div>
<div class="da-value advance">&#8377; 4,162.65 Cr</div>
<div class="da-value advance">&#8377; 16,277.11 Cr</div>
<div class="da-bar"><div class="da-fill advance-fill" style="width: 100%"></div></div>
</div>
</div>
@@ -0,0 +1,169 @@
<!--
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="fdrd-dlg">
<!-- HEADER -->
<div class="fdrd-head">
<div class="fdrd-head-left">
<div class="fdrd-head-ic"><fa-icon icon="piggy-bank"></fa-icon></div>
<div>
<h2 class="fdrd-title">FD / RD Entry</h2>
<p class="fdrd-sub">Open a new Fixed Deposit or Recurring Deposit account.</p>
</div>
</div>
<div class="fdrd-head-actions">
<span class="fdrd-meta"> <fa-icon icon="university"></fa-icon>&nbsp;{{ bank.name }} </span>
<span class="fdrd-meta"> <fa-icon icon="map-marker-alt"></fa-icon>&nbsp;{{ bank.branch }} </span>
<button class="fdrd-close" (click)="close()" matTooltip="Close" aria-label="Close">&#10005;</button>
</div>
</div>
<div class="fdrd-body">
<!-- Success banner -->
<div class="fdrd-success" *ngIf="saved">
<fa-icon icon="check-circle"></fa-icon>
<div>
<strong>{{ lastAccountNo }}</strong> created for <strong>{{ lastClientName }}</strong>
<div class="fdrd-success-sub">You can enter another deposit below, or close this dialog.</div>
</div>
</div>
<!-- FORM -->
<div class="fdrd-section">
<!-- Row 1: Deposit Info | Client & Amount -->
<div class="ws-group-row">
<div class="ws-group">
<span class="ws-caption">Deposit Info</span>
<div class="ws-group__fields">
<div class="ws-f" style="width: 200px">
<label class="ws-f__lbl">Deposit Type</label>
<select class="ws-f__ctl" [(ngModel)]="depositType" (ngModelChange)="onDepositTypeChange()">
<option *ngFor="let dt of depositTypes" [value]="dt.code">{{ dt.label }}</option>
</select>
</div>
<div class="ws-f" style="width: 150px">
<label class="ws-f__lbl">Pay Mode</label>
<select class="ws-f__ctl" [(ngModel)]="payMode">
<option *ngFor="let m of payModes" [value]="m">{{ m }}</option>
</select>
</div>
</div>
</div>
<div class="ws-vdivider"></div>
<div class="ws-group ws-group--flex">
<span class="ws-caption">Client & Amount</span>
<div class="ws-group__fields">
<div class="ws-f" style="flex: 1.5; min-width: 180px">
<label class="ws-f__lbl">Client</label>
<select class="ws-f__ctl" [(ngModel)]="clientId">
<option value="" disabled>-- Select Client --</option>
<option *ngFor="let c of clients" [value]="c.id">{{ c.name }} ({{ c.id }})</option>
</select>
</div>
<div class="ws-f" style="flex: 1; min-width: 120px">
<label class="ws-f__lbl">{{ isRecurring ? 'Monthly Instalment (₹)' : 'Deposit Amount (₹)' }}</label>
<input
class="ws-f__ctl ws-f__ctl--mono"
type="number"
min="0"
[(ngModel)]="principal"
placeholder="e.g. 100000"
/>
</div>
</div>
</div>
</div>
<!-- Row 2: Term & Rate | Instructions -->
<div class="ws-group-row ws-group-row--mt">
<div class="ws-group">
<span class="ws-caption">Interest & Tenure</span>
<div class="ws-group__fields">
<div class="ws-f" style="width: 110px">
<label class="ws-f__lbl">Int. Rate (% p.a.)</label>
<input class="ws-f__ctl" type="number" min="0" step="0.05" [(ngModel)]="interestRate" />
</div>
<div class="ws-f" style="width: 240px">
<label class="ws-f__lbl">Tenure</label>
<div class="fdrd-tenure-row">
<input class="ws-f__ctl" type="number" min="0" [(ngModel)]="tenureYears" />
<span>Yrs</span>
<input class="ws-f__ctl" type="number" min="0" max="11" [(ngModel)]="tenureMonths" />
<span>Mo</span>
<input class="ws-f__ctl" type="number" min="0" max="30" [(ngModel)]="tenureDays" />
<span>Days</span>
</div>
</div>
</div>
</div>
<div class="ws-vdivider"></div>
<div class="ws-group ws-group--flex">
<span class="ws-caption">Nominee & Maturity Details</span>
<div class="ws-group__fields">
<div class="ws-f" style="flex: 1; min-width: 120px">
<label class="ws-f__lbl">Interest Payout</label>
<select class="ws-f__ctl" [(ngModel)]="interestPayout">
<option *ngFor="let o of interestPayoutOptions" [value]="o">{{ o }}</option>
</select>
</div>
<div class="ws-f" style="flex: 1; min-width: 130px">
<label class="ws-f__lbl">Nominee Name (optional)</label>
<input class="ws-f__ctl" type="text" [(ngModel)]="nomineeName" placeholder="Spouse / Child name" />
</div>
<div class="ws-f" style="flex: 1.2; min-width: 160px">
<label class="ws-f__lbl">Maturity Instruction</label>
<select class="ws-f__ctl" [(ngModel)]="maturityInstruction">
<option *ngFor="let m of maturityInstructions" [value]="m">{{ m }}</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- SUMMARY -->
<div class="fdrd-summary">
<div class="fdrd-stat">
<div class="fdrd-stat-l">Principal</div>
<div class="fdrd-stat-v">&#8377; {{ principal || 0 | number: '1.0-2' }}</div>
</div>
<div class="fdrd-stat">
<div class="fdrd-stat-l">Tenure</div>
<div class="fdrd-stat-v">{{ tenureYears }}y {{ tenureMonths }}m {{ tenureDays }}d</div>
</div>
<div class="fdrd-stat">
<div class="fdrd-stat-l">Maturity Date</div>
<div class="fdrd-stat-v">{{ maturityDate | date: 'dd-MM-yyyy' }}</div>
</div>
<div class="fdrd-stat fdrd-stat-good">
<div class="fdrd-stat-l">Maturity Amount</div>
<div class="fdrd-stat-v">&#8377; {{ maturityAmount | number: '1.2-2' }}</div>
</div>
</div>
</div>
<!-- FOOTER -->
<div class="fdrd-foot">
<div class="fdrd-foot-l">
<fa-icon icon="info-circle"></fa-icon>&nbsp; Demo entry — quarterly-compounded interest, no real account is
created.
</div>
<div class="fdrd-foot-actions">
<button mat-stroked-button class="fdrd-btn-ghost" (click)="resetForm()">
<fa-icon icon="undo"></fa-icon>&nbsp; Reset
</button>
<button mat-stroked-button class="fdrd-btn-save" [disabled]="!isValid" (click)="save()">
<fa-icon icon="save"></fa-icon>&nbsp; Save
</button>
</div>
</div>
</div>
@@ -0,0 +1,395 @@
/**
* 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;
font-family: 'Hanken Grotesk', Inter, sans-serif;
color: #1a2a3e;
}
.fdrd-dlg {
background: #fff;
border-radius: 0;
display: flex;
flex-direction: column;
overflow: hidden;
height: 100vh;
}
/* HEADER - Matches .ws-header in clerk dashboard */
.fdrd-head {
padding: 0 12px;
height: 32px;
background: linear-gradient(90deg, #0d1b2e 0%, #162542 100%);
border-bottom: 2px solid #e87722;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-shrink: 0;
}
.fdrd-head-left {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.fdrd-head-ic {
width: 22px;
height: 22px;
border-radius: 6px;
background: #e87722;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
flex-shrink: 0;
}
.fdrd-title {
margin: 0;
font-family: Newsreader, Georgia, serif;
font-size: 14px;
font-weight: 500;
color: #e8f0fb;
letter-spacing: 0.01em;
}
.fdrd-sub {
display: none; /* Hide subtitle in header to match clean style */
}
.fdrd-head-actions {
display: flex;
align-items: center;
gap: 12px;
}
.fdrd-meta {
display: inline-flex;
align-items: center;
font-size: 11px;
color: #7a95b0;
background: transparent;
border: none;
padding: 0;
fa-icon {
color: #5a7589;
margin-right: 4px;
}
}
.fdrd-close {
background: transparent;
border: none;
color: #7a95b0;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 4px;
transition: all 0.15s;
&:hover {
background: rgb(255 255 255 / 10%);
color: #fff;
}
}
/* BODY - Styled like canvas + card grid */
.fdrd-body {
padding: 16px 20px;
overflow-y: auto;
flex: 1;
background: #f0f4f9;
}
.fdrd-success {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 16px;
border-radius: 10px;
background: #dcfce7;
color: #15803d;
margin-bottom: 14px;
font-size: 13px;
fa-icon {
font-size: 16px;
margin-top: 1px;
}
}
.fdrd-success-sub {
margin-top: 2px;
font-size: 11px;
font-weight: 400;
opacity: 0.85;
}
/* Card container for form */
.fdrd-section {
background: #fff;
border: 1px solid #dce4ef;
border-radius: 10px;
padding: 16px 20px;
margin-bottom: 16px;
}
/* Transaction Entry Style Layout mappings */
.ws-group-row {
display: flex;
align-items: stretch;
gap: 0;
&--mt {
margin-top: 12px;
}
}
.ws-group {
display: flex;
flex-direction: column;
gap: 4px;
flex-shrink: 0;
&--flex {
flex: 1;
min-width: 0;
}
&__fields {
display: flex;
align-items: flex-end;
gap: 8px;
flex-wrap: nowrap;
height: 100%;
align-content: flex-end;
}
}
.ws-caption {
font-family: Newsreader, Georgia, serif;
font-style: italic;
font-size: 11px;
color: #7a95b0;
padding-bottom: 2px;
}
.ws-vdivider {
width: 1px;
background: #dce4ef;
margin: 12px 10px 0;
flex-shrink: 0;
}
.ws-f {
display: flex;
flex-direction: column;
gap: 3px;
flex-shrink: 0;
&__lbl {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #7a95b0;
white-space: nowrap;
}
&__ctl {
height: 26px;
padding: 0 7px;
border: 1px solid #c8d8eb;
border-radius: 6px;
font-family: 'Hanken Grotesk', Inter, sans-serif;
font-size: 11.5px;
color: #1a2a3e;
background: #f4f7fc;
box-sizing: border-box;
width: 100%;
transition:
border-color 0.15s,
box-shadow 0.15s;
&:hover {
border-color: #9ab5d0;
}
&:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgb(37 99 235 / 12%);
background: #fff;
}
&--mono {
font-family: 'Roboto Mono', monospace;
font-size: 11.5px;
letter-spacing: 0.03em;
}
}
}
.fdrd-tenure-row {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
.ws-f__ctl {
width: 100%;
min-width: 0;
}
span {
font-size: 10px;
font-weight: 600;
color: #7a95b0;
text-transform: uppercase;
}
}
/* SUMMARY - Styled like info summary block */
.fdrd-summary {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
padding: 12px 16px;
background: #f4f7fc;
border: 1px solid #c8d8eb;
border-radius: 10px;
margin-top: 16px;
}
.fdrd-stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.fdrd-stat-l {
font-size: 10px;
font-weight: 600;
color: #7a95b0;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.fdrd-stat-v {
font-size: 14px;
font-weight: 700;
color: #1a2a3e;
}
.fdrd-stat-good .fdrd-stat-v {
color: #1a7a4a;
}
/* FOOTER - Styled like action panel */
.fdrd-foot {
padding: 10px 16px;
border-top: 1px solid #dce4ef;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
background: #f0f4f9;
}
.fdrd-foot-l {
font-size: 11px;
color: #7a95b0;
display: flex;
align-items: center;
gap: 4px;
}
.fdrd-foot-actions {
display: flex;
gap: 8px;
}
.fdrd-btn-ghost {
display: inline-flex;
align-items: center;
gap: 4px;
height: 28px;
padding: 0 12px !important;
border-radius: 7px;
border: 1px solid #dce4ef !important;
background: #fff !important;
color: #1a2a3e !important;
font-family: 'Hanken Grotesk', sans-serif;
font-size: 11.5px !important;
font-weight: 600 !important;
cursor: pointer;
box-shadow: none !important;
&:hover {
background: #f0f4f9 !important;
border-color: #a8bdd4 !important;
}
}
.fdrd-btn-save {
display: inline-flex;
align-items: center;
gap: 4px;
height: 28px;
padding: 0 16px !important;
border-radius: 7px;
border: none !important;
background: #e87722 !important;
color: #fff !important;
font-family: 'Hanken Grotesk', sans-serif;
font-size: 11.5px !important;
font-weight: 700 !important;
cursor: pointer;
box-shadow: 0 2px 8px rgb(232 119 34 / 30%) !important;
&:hover:not(:disabled) {
filter: brightness(1.08);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
background: #c8d8eb !important;
box-shadow: none !important;
}
}
@media (width <= 768px) {
.ws-group-row {
flex-direction: column;
gap: 12px;
}
.ws-vdivider {
display: none;
}
.ws-group__fields {
flex-wrap: wrap;
}
.fdrd-summary {
grid-template-columns: repeat(2, 1fr);
}
}
@@ -0,0 +1,146 @@
/**
* 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, inject } from '@angular/core';
import { CommonModule, DecimalPipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatSnackBar } from '@angular/material/snack-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
type DepositType = 'FD-SINGLE' | 'FD-JOINT' | 'RD-MONTHLY' | 'RD-QUARTERLY';
interface DemoClient {
id: string;
name: string;
}
@Component({
selector: 'mifosx-fd-rd-entry-dialog',
standalone: true,
templateUrl: './fd-rd-entry-dialog.component.html',
styleUrls: ['./fd-rd-entry-dialog.component.scss'],
imports: [CommonModule, DecimalPipe, FormsModule, MatDialogModule, MatButtonModule, MatTooltipModule, FaIconComponent]
})
export class FdRdEntryDialogComponent {
private dialogRef = inject(MatDialogRef<FdRdEntryDialogComponent>);
private snackBar = inject(MatSnackBar);
bank = {
name: 'CBSNexora Bank Ltd',
branch: 'Head Office'
};
depositTypes: { code: DepositType; label: string; defaultRate: number }[] = [
{ code: 'FD-SINGLE', label: 'Fixed Deposit — Single A/c', defaultRate: 7.25 },
{ code: 'FD-JOINT', label: 'Fixed Deposit — Joint A/c', defaultRate: 7.25 },
{ code: 'RD-MONTHLY', label: 'Recurring Deposit — Monthly', defaultRate: 7.0 },
{ code: 'RD-QUARTERLY', label: 'Recurring Deposit — Quarterly', defaultRate: 7.0 }
];
clients: DemoClient[] = [
{ id: 'C-10231', name: 'Ramesh Kumar Sharma' },
{ id: 'C-10344', name: 'Priya Enterprises' },
{ id: 'C-10412', name: 'Anjali Verma' },
{ id: 'C-10567', name: 'Suresh & Sons HUF' },
{ id: 'C-10689', name: 'Vikram Traders' }
];
payModes = ['Cash', 'Cheque', 'Transfer from Savings A/c'];
interestPayoutOptions = ['Cumulative (Paid on Maturity)', 'Monthly Payout', 'Quarterly Payout'];
maturityInstructions = [
'Renew Principal & Interest',
'Renew Principal Only, Payout Interest',
'Payout on Maturity (No Renewal)'
];
depositType: DepositType = 'FD-SINGLE';
clientId = '';
principal: number | null = null;
interestRate = 7.25;
tenureYears = 1;
tenureMonths = 0;
tenureDays = 0;
interestPayout = this.interestPayoutOptions[0];
payMode = this.payModes[0];
nomineeName = '';
maturityInstruction = this.maturityInstructions[0];
saved = false;
lastAccountNo = '';
lastClientName = '';
get selectedClient(): DemoClient | undefined {
return this.clients.find((c) => c.id === this.clientId);
}
onDepositTypeChange(): void {
const dt = this.depositTypes.find((d) => d.code === this.depositType);
if (dt) this.interestRate = dt.defaultRate;
}
get isRecurring(): boolean {
return this.depositType.startsWith('RD');
}
get tenureInYears(): number {
return this.tenureYears + this.tenureMonths / 12 + this.tenureDays / 365;
}
get maturityDate(): Date {
const d = new Date();
d.setFullYear(d.getFullYear() + this.tenureYears);
d.setMonth(d.getMonth() + this.tenureMonths);
d.setDate(d.getDate() + this.tenureDays);
return d;
}
/** Quarterly-compounded maturity value, the standard FD/RD convention */
get maturityAmount(): number {
const p = this.principal || 0;
const t = this.tenureInYears;
if (p <= 0 || t <= 0) return 0;
return p * Math.pow(1 + this.interestRate / 100 / 4, 4 * t);
}
get isValid(): boolean {
return !!this.clientId && (this.principal ?? 0) > 0 && this.tenureInYears > 0;
}
save(): void {
if (!this.isValid) return;
const prefix = this.isRecurring ? 'RD' : 'FD';
this.lastAccountNo = `${prefix}${Math.floor(1000000 + Math.random() * 8999999)}`;
this.lastClientName = this.selectedClient?.name ?? '';
this.saved = true;
this.snackBar.open(`${prefix} account ${this.lastAccountNo} created for ${this.lastClientName}`, 'Close', {
duration: 4000
});
}
resetForm(): void {
this.depositType = 'FD-SINGLE';
this.clientId = '';
this.principal = null;
this.interestRate = 7.25;
this.tenureYears = 1;
this.tenureMonths = 0;
this.tenureDays = 0;
this.nomineeName = '';
this.interestPayout = this.interestPayoutOptions[0];
this.payMode = this.payModes[0];
this.maturityInstruction = this.maturityInstructions[0];
this.saved = false;
}
close(): void {
this.dialogRef.close();
}
}
@@ -63,7 +63,7 @@ export class PslReportDialogComponent {
activeTab: 'dashboard' | 'categories' | 'register' | 'returns' = 'dashboard';
bank = {
name: 'Bharat Urban Bank Ltd',
name: 'CBSNexora Bank Ltd',
branch: 'Head Office',
quarter: 'Q3 FY 2025-26',
asOnDate: '29-01-2026',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,213 @@
/**
* 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, ElementRef, ViewChild, forwardRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
/**
* Drop-in replacement for `<input type="text" placeholder="DD-MM-YYYY">` used throughout the
* Investment module. Keeps the DD-MM-YYYY string every date field/helper in investment-data.ts
* already expects (parseDDMMYYYY, addPeriod, etc.), but adds a calendar button that opens the
* browser's native date picker and converts the picked value back to DD-MM-YYYY — so users can
* either type the date or pick it, without changing any consuming component's data model.
*
* Usage: <mifosx-date-ddmmyyyy [(ngModel)]="someField"></mifosx-date-ddmmyyyy>
*/
@Component({
selector: 'mifosx-date-ddmmyyyy',
standalone: true,
imports: [
CommonModule,
FormsModule
],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => DateDdmmyyyyPickerComponent),
multi: true
}
],
template: `
<div class="inv-date-field">
<input
type="text"
class="inv-date-field__text"
[ngModel]="value"
(ngModelChange)="onTextChange($event)"
(blur)="onTouched()"
placeholder="DD-MM-YYYY"
[disabled]="disabled"
/>
<button
type="button"
class="inv-date-field__btn"
tabindex="-1"
[disabled]="disabled"
(click)="openPicker()"
aria-label="Pick a date"
title="Pick a date"
>
📅
</button>
<input
#native
type="date"
class="inv-date-field__native"
tabindex="-1"
[ngModel]="isoValue"
(ngModelChange)="onPickerChange($event)"
/>
</div>
`,
styles: [
`
/* Angular view encapsulation stops the host page's .inv-form-field styles from reaching
* inside this component's own template, so the look of the surrounding form fields
* (height, border, radius, font) is deliberately re-declared here instead of inherited. */
:host {
display: block;
width: 100%;
}
.inv-date-field {
position: relative;
display: flex;
align-items: center;
gap: 6px;
width: 100%;
}
.inv-date-field__text {
flex: 1;
min-width: 0;
width: 100%;
height: 36px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
color: #0d1b2a;
font-family: inherit;
background: #fff;
box-sizing: border-box;
}
.inv-date-field__btn {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
height: 36px;
width: 36px;
padding: 0;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
cursor: pointer;
font-size: 14px;
line-height: 1;
}
.inv-date-field__btn:hover:not(:disabled) {
background: #f2f4f7;
}
.inv-date-field__btn:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.inv-date-field__native {
position: absolute;
width: 0;
height: 0;
padding: 0;
border: none;
opacity: 0;
pointer-events: none;
}
`
]
})
export class DateDdmmyyyyPickerComponent implements ControlValueAccessor {
@ViewChild('native') private nativeInput?: ElementRef<HTMLInputElement>;
value = '';
disabled = false;
private onChangeFn: (v: string) => void = () => {};
onTouched: () => void = () => {};
/** DD-MM-YYYY -> yyyy-MM-dd, the format <input type="date"> requires. */
get isoValue(): string {
const parts = (this.value || '').split('-');
if (parts.length !== 3) {
return '';
}
const [
dd,
mm,
yyyy
] = parts;
if (!/^\d{2}$/.test(dd) || !/^\d{2}$/.test(mm) || !/^\d{4}$/.test(yyyy)) {
return '';
}
return `${yyyy}-${mm}-${dd}`;
}
writeValue(v: string): void {
this.value = v ?? '';
}
registerOnChange(fn: (v: string) => void): void {
this.onChangeFn = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
onTextChange(v: string): void {
this.value = v;
this.onChangeFn(v);
}
/** yyyy-MM-dd (from the native picker) -> DD-MM-YYYY, the format the rest of the app expects. */
onPickerChange(iso: string): void {
const parts = (iso || '').split('-');
if (parts.length !== 3) {
return;
}
const [
yyyy,
mm,
dd
] = parts;
const ddmmyyyy = `${dd}-${mm}-${yyyy}`;
this.value = ddmmyyyy;
this.onChangeFn(ddmmyyyy);
this.onTouched();
}
openPicker(): void {
const el = this.nativeInput?.nativeElement;
if (!el || this.disabled) {
return;
}
const showPicker = (el as HTMLInputElement & { showPicker?: () => void }).showPicker;
if (typeof showPicker === 'function') {
try {
showPicker.call(el);
return;
} catch {
// Some browsers throw if not called from a "user gesture" context they recognise —
// fall back to a plain click(), which still opens the native picker.
}
}
el.click();
}
}
@@ -0,0 +1,495 @@
<!--
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="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">TREASURY · INVESTMENT · FIXED DEPOSIT</div>
<h1 class="pg-title">FD Investment</h1>
<p class="pg-sub">
Bank's own Fixed Deposit investments with other banks — Purchase, Interest, Sell/Premature, Write-off and
Part-Payment entries.
</p>
</div>
@if (activeTab === 'holdings') {
<div class="pg-toolbar">
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
</div>
}
</header>
<div class="cctr-tabs">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'holdings'"
(click)="setTab('holdings')"
>
Holdings
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'purchase'"
(click)="setTab('purchase')"
>
01 Purchase Entry
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'interest'"
(click)="setTab('interest')"
>
02 Interest Entry
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'sell'"
(click)="setTab('sell')"
>
03 Sell / Premature Entry
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'writeoff'"
(click)="setTab('writeoff')"
>
04 Write Off Entry
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'partpayment'"
(click)="setTab('partpayment')"
>
05 FD Part Payment Entry
</button>
</div>
<!-- HOLDINGS -->
@if (activeTab === 'holdings') {
<div class="pg-card">
<div class="pos-section-label">FD Holdings Register</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Party</th>
<th>Type</th>
<th class="num">Face Value</th>
<th class="num">Rate</th>
<th>Purchase Date</th>
<th>Maturity Date</th>
<th class="num col-in">Accrued Interest</th>
<th class="num col-net">Maturity Value</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (h of holdings; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.party }}</td>
<td>{{ h.interestFreq === 'CUMULATIVE' ? 'Cumulative' : 'Payout' }}</td>
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
<td class="num">{{ h.interestRate }}%</td>
<td>{{ h.purchaseDate }}</td>
<td>{{ h.maturityDate }}</td>
<td class="num col-in">₹{{ accrued(h) | number: '1.2-2' }}</td>
<td class="num col-net">₹{{ maturity(h) | number: '1.2-2' }}</td>
<td>
<span [class]="statusClass(h)">{{ h.status }}</span>
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- PURCHASE ENTRY -->
@if (activeTab === 'purchase') {
<div class="pg-card">
<div class="pos-section-label">Investment - FD Purchase Entry</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Tran. Type</span>
<select [(ngModel)]="purchaseForm.tranType">
<option value="FRESH">FRESH</option>
<option value="RENEWAL">RENEWAL</option>
</select>
</label>
@if (purchaseForm.tranType === 'RENEWAL') {
<label class="inv-form-field"
><span>OLD CertNo</span><input type="text" [(ngModel)]="purchaseForm.oldCertNo"
/></label>
}
<label class="inv-form-field">
<span>FD Type</span>
<select [(ngModel)]="purchaseForm.fdType">
@for (t of fdTypeOptions; track t) {
<option [value]="t">{{ t }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Mature Amt.calc.Type</span>
<select [(ngModel)]="purchaseForm.matureAmtCalcType">
<option value="GENERAL">GENERAL</option>
<option value="DATEWISE">DATEWISE</option>
</select>
</label>
<label class="inv-form-field">
<span>Party *</span>
<select [(ngModel)]="purchaseForm.party">
<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 Party</span>
<select [(ngModel)]="purchaseForm.subParty">
<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>GL ACNO</span><input type="text" [(ngModel)]="purchaseForm.glAcNo"
/></label>
<label class="inv-form-field"
><span>GL A/c Name</span><input type="text" [(ngModel)]="purchaseForm.glAcName" readonly
/></label>
<label class="inv-form-field"
><span>Certificate No.</span><input type="text" [(ngModel)]="purchaseForm.certNo"
/></label>
<label class="inv-form-field"
><span>Certificate Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.certDate"></mifosx-date-ddmmyyyy
></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.subTypeCode }} - {{ s.subTypeName }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Reciept/Advice</span>
<select [(ngModel)]="purchaseForm.receiptAdvice">
<option value="RECEIPT">RECEIPT</option>
<option value="ADVICE">ADVICE</option>
</select>
</label>
<label class="inv-form-field"
><span>Transaction Date</span
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.transactionDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Deposit Date *</span
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.depositDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Deposit Amount *</span><input type="number" [(ngModel)]="purchaseForm.depositAmount" min="0"
/></label>
<label class="inv-form-field">
<span>Interest[M/Q/H/Y]</span>
<select [(ngModel)]="purchaseForm.interestFreq">
@for (i of intTypeOptions; track i.value) {
<option [value]="i.value">{{ i.label }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Interest Date</span
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.interestDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Interest Rate *</span><input type="number" [(ngModel)]="purchaseForm.interestRate" min="0" step="0.01"
/></label>
@if (purchaseForm.matureAmtCalcType === 'GENERAL') {
<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>Year</span><input type="number" [(ngModel)]="purchaseForm.periodYear" min="0"
/></label>
<label class="inv-form-field"
><span>Days</span><input type="number" [(ngModel)]="purchaseForm.periodDays" min="0"
/></label>
} @else {
<label class="inv-form-field"
><span>Maturity Date (manual) *</span
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.manualMaturityDate"></mifosx-date-ddmmyyyy
></label>
}
<label class="inv-form-field"
><span>Resolution No.</span><input type="text" [(ngModel)]="purchaseForm.resolutionNo"
/></label>
<label class="inv-form-field">
<span>CR. Branch Code</span>
<select [(ngModel)]="purchaseForm.crBranchCode">
@for (b of branchOptions; track b) {
<option [value]="b">{{ b }}</option>
}
</select>
</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>CR. A/c Name</span><input type="text" [(ngModel)]="purchaseForm.crToAcName" readonly
/></label>
<label class="inv-form-field"
><span>Int.Receivable Code</span><input type="text" [(ngModel)]="purchaseForm.intReceivableCode"
/></label>
<label class="inv-form-field">
<span>LIEN Y/N</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 AMT.</span><input type="number" [(ngModel)]="purchaseForm.lienAmt"
/></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>Classification</span>
<select [(ngModel)]="purchaseForm.classification">
<option value="">-- Select --</option>
<option value="STANDARD">STANDARD</option>
<option value="SUB-STANDARD">SUB-STANDARD</option>
<option value="DOUBTFUL">DOUBTFUL</option>
</select>
</label>
<label class="inv-form-field">
<span>Collable</span>
<select [(ngModel)]="purchaseForm.collable">
<option value="NO">NO</option>
<option value="YES">YES</option>
</select>
</label>
</div>
<div class="pos-section-label">Mature / Premature Details</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>* Maturity Date</span><input type="text" [value]="purchaseMaturityDate" readonly
/></label>
<label class="inv-form-field"
><span>* Maturity Amt</span><input type="text" [value]="purchaseMaturityAmount | number: '1.0-0'" readonly
/></label>
<label class="inv-form-field"
><span>Search by Cert No</span><input type="text" [(ngModel)]="searchCertNo" placeholder="Enter Cert No"
/></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)="cancelPurchase()">Cancel</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="searchPurchase()">Search</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="dropPurchase()">Drop</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="exitPurchase()">Exit</button>
</div>
</div>
}
<!-- INTEREST ENTRY -->
@if (activeTab === 'interest') {
<div class="pg-card">
<div class="pos-section-label">Investment - FD Interest Entry / Calculation</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Certificate No *</span>
<select [(ngModel)]="interestCertNo">
<option value="">-- Select --</option>
@for (h of interestEligibleHoldings; track h.certNo) {
<option [value]="h.certNo">{{ h.certNo }} — {{ h.party }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>From Date</span><mifosx-date-ddmmyyyy [(ngModel)]="interestFromDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>To Date</span><mifosx-date-ddmmyyyy [(ngModel)]="interestToDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field">
<span>Post To (Interest Income GL)</span>
<select [(ngModel)]="interestGlCode">
@for (g of interestIncomeGlOptions; track g.glCode) {
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
}
</select>
</label>
@if (interestHolding; as h) {
<label class="inv-form-field"><span>Party</span><input type="text" [value]="h.party" readonly /></label>
<label class="inv-form-field"
><span>Interest Rate</span><input type="text" [value]="h.interestRate + '%'" readonly
/></label>
<label class="inv-form-field"
><span>Frequency</span><input type="text" [value]="h.interestFreq" readonly
/></label>
<label class="inv-form-field"
><span>Interest for Period</span
><input type="text" [value]="'₹' + (interestAccrued(h) | number: '1.2-2')" readonly
/></label>
}
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="postInterest()">Process / Post Interest</button>
</div>
</div>
}
<!-- SELL / PREMATURE ENTRY -->
@if (activeTab === 'sell') {
<div class="pg-card">
<div class="pos-section-label">Investment - FD Sell / Premature Closure Entry</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Certificate No *</span>
<select [(ngModel)]="sellCertNo">
<option value="">-- Select --</option>
@for (h of activeHoldings; track h.certNo) {
<option [value]="h.certNo">{{ h.certNo }} — {{ h.party }}</option>
}
</select>
</label>
@if (sellHolding; as h) {
<label class="inv-form-field"
><span>Face Value</span><input type="text" [value]="'₹' + (h.faceValue | number: '1.2-2')" readonly
/></label>
<label class="inv-form-field"
><span>Accrued Interest till Sell Date</span
><input type="text" [value]="'₹' + (accrued(h) | number: '1.2-2')" readonly
/></label>
}
<label class="inv-form-field"
><span>Sell / Premature Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="sellDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"><span>Remarks</span><input type="text" [(ngModel)]="sellRemark" /></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="saveSell()">Save</button>
</div>
</div>
}
<!-- WRITE OFF ENTRY -->
@if (activeTab === 'writeoff') {
<div class="pg-card">
<div class="pos-section-label">Investment - FD Write Off Entry</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Certificate No *</span>
<select [(ngModel)]="writeoffCertNo">
<option value="">-- Select --</option>
@for (h of activeHoldings; track h.certNo) {
<option [value]="h.certNo">{{ h.certNo }} — {{ h.party }}</option>
}
</select>
</label>
@if (writeoffHolding; as h) {
<label class="inv-form-field"
><span>Face Value to be Written Off</span
><input type="text" [value]="'₹' + (h.faceValue | number: '1.2-2')" readonly
/></label>
}
<label class="inv-form-field"><span>Reason *</span><input type="text" [(ngModel)]="writeoffReason" /></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="saveWriteoff()">Save</button>
</div>
</div>
}
<!-- PART PAYMENT ENTRY -->
@if (activeTab === 'partpayment') {
<div class="pg-card">
<div class="pos-section-label">Investment - FD Part Payment Entry</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Certificate No *</span>
<select [(ngModel)]="partPayCertNo">
<option value="">-- Select --</option>
@for (h of activeHoldings; track h.certNo) {
<option [value]="h.certNo">{{ h.certNo }} — {{ h.party }}</option>
}
</select>
</label>
@if (partPayHolding; as h) {
<label class="inv-form-field"
><span>Current Face Value</span><input type="text" [value]="'₹' + (h.faceValue | number: '1.2-2')" readonly
/></label>
}
<label class="inv-form-field"
><span>Part Payment Amount *</span><input type="number" [(ngModel)]="partPayAmount" min="0"
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="savePartPayment()">Save</button>
</div>
</div>
}
@if (activeTab !== 'holdings') {
<div class="pg-card">
<div class="pos-section-label">FD Transaction Log</div>
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>Voucher No</th>
<th>Cert No</th>
<th>Type</th>
<th>Particulars</th>
<th>GL Posted</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (t of txns; track t.voucherNo) {
<tr>
<td>{{ t.txnDate }}</td>
<td>
<span class="pg-chip">{{ t.voucherNo }}</span>
</td>
<td>{{ t.certNo }}</td>
<td>{{ t.txnType }}</td>
<td>{{ t.particulars }}</td>
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
<td class="num">₹{{ t.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
@@ -0,0 +1,562 @@
/**
* 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 } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import {
FD_HOLDINGS,
ALL_TXNS,
PARTY_MASTER,
SUBTYPE_MASTER,
FD_TYPE_OPTIONS,
INT_TYPE_OPTIONS,
LIEN_REASON_MASTER,
INTEREST_INCOME_GL_MASTER,
INTEREST_INCOME_GL_BY_TYPE,
InvestmentHolding,
InvestmentTxn,
InterestFreq,
accruedInterest,
accruedInterestBetween,
canAccrueInterest,
daysToMaturity,
maturityAmount,
addPeriod,
todayDDMMYYYY
} from '../investment-data';
import { exportWorkbook } from '../investment-export';
type FdTab = 'holdings' | 'purchase' | 'interest' | 'sell' | 'writeoff' | 'partpayment';
interface FdPurchaseForm {
tranType: 'FRESH' | 'RENEWAL';
oldCertNo: string;
fdType: string;
/** GENERAL uses the period (Y/M/D) fields; DATEWISE lets the user type the maturity date directly. */
matureAmtCalcType: 'GENERAL' | 'DATEWISE';
certNo: string;
certDate: string;
party: string;
subParty: string;
glAcNo: string;
glAcName: string;
subType: string;
receiptAdvice: 'RECEIPT' | 'ADVICE';
transactionDate: string;
depositDate: string;
depositAmount: number | null;
interestFreq: InterestFreq;
interestDate: string;
interestRate: number | null;
periodYear: number | null;
periodMonth: number | null;
periodDays: number | null;
/** Only used when matureAmtCalcType === 'DATEWISE'. */
manualMaturityDate: string;
resolutionNo: string;
crBranchCode: string;
crToAc: string;
crToAcName: string;
intReceivableCode: string;
lien: 'YES' | 'NO';
lienAmt: number | null;
lienReason: string;
classification: string;
collable: 'YES' | 'NO';
}
/**
* FD (Fixed Deposit) treasury investment screen — Holdings register + Purchase / Interest /
* Sell-Premature / Write-off / Part-Payment entry tabs, modelled on the legacy "Investment - FD
* Purchase Entry" form. Dummy in-memory data only; Save appends to the local holdings/txns
* arrays so the demo feels interactive without any backend.
*/
@Component({
selector: 'mifosx-fd-investment',
templateUrl: './fd-investment.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../investment-shared.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
DateDdmmyyyyPickerComponent
]
})
export class FdInvestmentComponent {
activeTab: FdTab = 'holdings';
asOnDate = todayDDMMYYYY();
holdings: InvestmentHolding[] = [...FD_HOLDINGS];
txns: InvestmentTxn[] = ALL_TXNS.filter((t) => t.instrumentType === 'FD');
readonly partyOptions = PARTY_MASTER;
readonly subTypeOptions = SUBTYPE_MASTER;
readonly fdTypeOptions = FD_TYPE_OPTIONS;
readonly intTypeOptions = INT_TYPE_OPTIONS;
readonly lienReasonOptions = LIEN_REASON_MASTER;
readonly interestIncomeGlOptions = INTEREST_INCOME_GL_MASTER;
readonly branchOptions = [
'HEAD OFFICE',
'BRANCH 1',
'BRANCH 2'
];
generating = false;
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
purchaseForm: FdPurchaseForm = this.blankPurchaseForm();
/** Certificate No typed into the Purchase Entry's Search box (legacy "Search" button). */
searchCertNo = '';
interestCertNo = '';
interestFromDate = addPeriod(this.asOnDate, 0, -3);
interestToDate = this.asOnDate;
interestGlCode = INTEREST_INCOME_GL_BY_TYPE.FD;
sellCertNo = '';
sellDate = this.asOnDate;
sellRemark = '';
writeoffCertNo = '';
writeoffReason = '';
partPayCertNo = '';
partPayAmount: number | null = null;
setTab(tab: FdTab): void {
this.activeTab = tab;
}
get activeHoldings(): InvestmentHolding[] {
return this.holdings.filter((h) => h.status === 'ACTIVE');
}
private nextCertNo(): string {
const nums = this.holdings.map((h) => parseInt(h.certNo, 10)).filter((n) => !isNaN(n));
return String((nums.length ? Math.max(...nums) : 15619) + 1);
}
private blankPurchaseForm(): FdPurchaseForm {
return {
tranType: 'FRESH',
oldCertNo: '',
fdType: 'CUMMULATIVE',
matureAmtCalcType: 'GENERAL',
certNo: this.nextCertNo(),
certDate: this.asOnDate,
party: '',
subParty: '',
glAcNo: '320020',
glAcName: 'FIXED DEPO. CO-OPER. BANK MAIN',
subType: 'NON SLR',
receiptAdvice: 'RECEIPT',
transactionDate: this.asOnDate,
depositDate: this.asOnDate,
depositAmount: null,
interestFreq: 'CUMULATIVE',
interestDate: this.asOnDate,
interestRate: null,
periodYear: 2,
periodMonth: 0,
periodDays: 0,
manualMaturityDate: '',
resolutionNo: '',
crBranchCode: 'HEAD OFFICE',
crToAc: '310070',
crToAcName: 'CURRENT A/C CO-OP. BANK MAIN BR.41',
intReceivableCode: '320020',
lien: 'NO',
lienAmt: null,
lienReason: '',
classification: '',
collable: 'NO'
};
}
get purchaseMaturityDate(): string {
const f = this.purchaseForm;
if (f.matureAmtCalcType === 'DATEWISE') {
return f.manualMaturityDate;
}
if (!f.depositDate) {
return '';
}
const base = addPeriod(f.depositDate, f.periodYear || 0, f.periodMonth || 0);
return f.periodDays ? addPeriod(base, 0, 0, f.periodDays) : base;
}
get purchaseMaturityAmount(): number {
const f = this.purchaseForm;
if (!f.depositAmount || !f.interestRate || !this.purchaseMaturityDate) {
return 0;
}
return maturityAmount({
certNo: f.certNo,
instrumentType: 'FD',
scriptCode: '',
scriptName: '',
party: f.party,
subType: f.subType,
faceValue: f.depositAmount,
purchaseRate: 100,
units: 1,
purchaseDate: f.depositDate,
maturityDate: this.purchaseMaturityDate,
interestRate: f.interestRate,
interestFreq: f.interestFreq,
glCode: f.glAcNo,
glName: '',
crToAc: f.crToAc,
brokerCode: '',
status: 'ACTIVE'
});
}
savePurchase(): void {
const f = this.purchaseForm;
if (!f.party || !f.depositAmount || !f.interestRate || !this.purchaseMaturityDate) {
this.toast('Please fill Party, Deposit Amount, Interest Rate and Period.');
return;
}
const holding: InvestmentHolding = {
certNo: f.certNo,
instrumentType: 'FD',
scriptCode: f.interestFreq === 'CUMULATIVE' ? 'FD-CUM' : 'FD-MIP',
scriptName: f.fdType ? `${f.fdType} Fixed Deposit` : 'Fixed Deposit',
party: f.party,
subType: f.subType,
faceValue: f.depositAmount,
purchaseRate: 100,
units: 1,
purchaseDate: f.depositDate,
maturityDate: this.purchaseMaturityDate,
interestRate: f.interestRate,
interestFreq: f.interestFreq,
glCode: f.glAcNo,
glName: f.glAcName,
crToAc: f.crToAc,
brokerCode: '',
status: 'ACTIVE'
};
this.holdings = [
holding,
...this.holdings
];
this.txns = [
{
certNo: holding.certNo,
instrumentType: 'FD',
txnType: 'PURCHASE',
voucherNo: `INV/PUR/FD-${holding.certNo}`,
txnDate: f.transactionDate,
amount: holding.faceValue,
particulars: `${f.tranType === 'RENEWAL' ? 'Renewal' : 'Fresh'} FD Purchase - ${holding.party}`
},
...this.txns
];
this.toast(`FD Certificate No. ${holding.certNo} saved successfully.`);
this.purchaseForm = this.blankPurchaseForm();
this.activeTab = 'holdings';
}
cancelPurchase(): void {
this.purchaseForm = this.blankPurchaseForm();
this.toast('Entry cancelled — form reset.');
}
/** Legacy "Search" button — loads an existing certificate's values back into the form. */
searchPurchase(): void {
const h = this.holdings.find((x) => x.certNo === this.searchCertNo.trim());
if (!h) {
this.toast(`Certificate No. ${this.searchCertNo} not found.`);
return;
}
this.purchaseForm = {
...this.blankPurchaseForm(),
tranType: 'FRESH',
certNo: h.certNo,
certDate: h.purchaseDate,
party: h.party,
subType: h.subType,
glAcNo: h.glCode,
glAcName: h.glName,
transactionDate: h.purchaseDate,
depositDate: h.purchaseDate,
depositAmount: h.faceValue,
interestFreq: h.interestFreq,
interestDate: h.purchaseDate,
interestRate: h.interestRate,
matureAmtCalcType: 'DATEWISE',
manualMaturityDate: h.maturityDate,
crToAc: h.crToAc
};
this.toast(`Certificate ${h.certNo} loaded.`);
}
/** Legacy "Drop" button — removes the loaded certificate from the register. */
dropPurchase(): void {
const certNo = this.purchaseForm.certNo;
const exists = this.holdings.some((h) => h.certNo === certNo);
if (!exists) {
this.toast('Nothing to drop — this certificate is not yet saved.');
return;
}
this.holdings = this.holdings.filter((h) => h.certNo !== certNo);
this.txns = this.txns.filter((t) => t.certNo !== certNo);
this.purchaseForm = this.blankPurchaseForm();
this.toast(`Certificate ${certNo} dropped.`);
}
/** Legacy "Exit" button — closes the entry form and returns to the register. */
exitPurchase(): void {
this.purchaseForm = this.blankPurchaseForm();
this.activeTab = 'holdings';
}
// -------------------- Interest --------------------
/** Certificates eligible for accrual-basis posting — same rule as the Investment Interest
* module: a Non-Performing holding never appears here, only through Interest Receive. */
get interestEligibleHoldings(): InvestmentHolding[] {
return this.activeHoldings.filter((h) => canAccrueInterest(h));
}
get interestHolding(): InvestmentHolding | undefined {
return this.holdings.find((h) => h.certNo === this.interestCertNo);
}
/** Interest accrued strictly within [interestFromDate, interestToDate] — a period-based
* Interest Calculation & Post run, same basis as the Investment Interest module. */
interestAccrued(h: InvestmentHolding): number {
return accruedInterestBetween(h, this.interestFromDate || h.purchaseDate, this.interestToDate || this.asOnDate);
}
glName(glCode: string): string {
return this.interestIncomeGlOptions.find((g) => g.glCode === glCode)?.glName ?? glCode;
}
postInterest(): void {
const h = this.interestHolding;
if (!h) {
this.toast('Select a certificate first.');
return;
}
const amt = this.interestAccrued(h);
if (amt <= 0) {
this.toast('No interest due to be posted for this period.');
return;
}
const toDate = this.interestToDate || this.asOnDate;
const fromDate = this.interestFromDate || toDate;
const glCode = this.interestGlCode;
const glName = this.glName(glCode);
h.interestPosted = (h.interestPosted ?? 0) + amt;
h.interestPostedTillDate = toDate;
this.interestFromDate = toDate;
this.txns = [
{
certNo: h.certNo,
instrumentType: 'FD',
txnType: 'INTEREST',
voucherNo: `INV/INT/FD-${h.certNo}-${this.txns.length + 1}`,
txnDate: toDate,
amount: amt,
particulars: `Interest posted (accrual) for period ${fromDate} to ${toDate} - ${h.party} (${h.interestFreq})`,
glCode,
glName
},
...this.txns
];
this.toast(
`Interest of ₹${amt.toLocaleString('en-IN')} posted to ${glCode} - ${glName} for Certificate ${h.certNo}.`
);
this.interestCertNo = '';
}
// -------------------- Sell / Premature --------------------
get sellHolding(): InvestmentHolding | undefined {
return this.holdings.find((h) => h.certNo === this.sellCertNo);
}
saveSell(): void {
const h = this.sellHolding;
if (!h || !this.sellDate) {
this.toast('Select a certificate and sell/premature date.');
return;
}
const payout = h.faceValue + accruedInterest(h, this.sellDate);
h.status = 'SOLD';
this.txns = [
{
certNo: h.certNo,
instrumentType: 'FD',
txnType: 'SELL',
voucherNo: `INV/SEL/FD-${h.certNo}`,
txnDate: this.sellDate,
amount: payout,
particulars: `Premature closure - ${h.party}${this.sellRemark ? ' - ' + this.sellRemark : ''}`
},
...this.txns
];
this.toast(`Certificate ${h.certNo} closed. Payout ₹${payout.toLocaleString('en-IN')}.`);
this.sellCertNo = '';
this.sellRemark = '';
this.activeTab = 'holdings';
}
// -------------------- Write-off --------------------
get writeoffHolding(): InvestmentHolding | undefined {
return this.holdings.find((h) => h.certNo === this.writeoffCertNo);
}
saveWriteoff(): void {
const h = this.writeoffHolding;
if (!h || !this.writeoffReason) {
this.toast('Select a certificate and enter a write-off reason.');
return;
}
h.status = 'WRITTEN_OFF';
this.txns = [
{
certNo: h.certNo,
instrumentType: 'FD',
txnType: 'WRITE_OFF',
voucherNo: `INV/WOF/FD-${h.certNo}`,
txnDate: this.asOnDate,
amount: h.faceValue,
particulars: `Written off - ${this.writeoffReason}`
},
...this.txns
];
this.toast(`Certificate ${h.certNo} written off.`);
this.writeoffCertNo = '';
this.writeoffReason = '';
this.activeTab = 'holdings';
}
// -------------------- Part Payment --------------------
get partPayHolding(): InvestmentHolding | undefined {
return this.holdings.find((h) => h.certNo === this.partPayCertNo);
}
savePartPayment(): void {
const h = this.partPayHolding;
if (!h || !this.partPayAmount || this.partPayAmount <= 0 || this.partPayAmount >= h.faceValue) {
this.toast('Select a certificate and enter a valid part-payment amount (less than face value).');
return;
}
h.faceValue -= this.partPayAmount;
this.txns = [
{
certNo: h.certNo,
instrumentType: 'FD',
txnType: 'PART_PAYMENT',
voucherNo: `INV/PP/FD-${h.certNo}-${this.txns.length + 1}`,
txnDate: this.asOnDate,
amount: this.partPayAmount,
particulars: `Part payment withdrawn - ${h.party}, balance ₹${h.faceValue.toLocaleString('en-IN')}`
},
...this.txns
];
this.toast(`Part payment of ₹${this.partPayAmount.toLocaleString('en-IN')} recorded for Certificate ${h.certNo}.`);
this.partPayCertNo = '';
this.partPayAmount = null;
this.activeTab = 'holdings';
}
// -------------------- Helpers --------------------
accrued(h: InvestmentHolding): number {
return accruedInterest(h, this.asOnDate);
}
maturity(h: InvestmentHolding): number {
return maturityAmount(h);
}
daysLeft(h: InvestmentHolding): number {
return daysToMaturity(h, this.asOnDate);
}
statusClass(h: InvestmentHolding): string {
return `inv-badge inv-badge--${h.status.toLowerCase()}`;
}
// -------------------- Export to Excel (Holdings register) --------------------
async exportExcel(): Promise<void> {
this.generating = true;
try {
await exportWorkbook(
[
{
sheetName: 'FD Holdings',
title: 'FD Holdings Register',
subtitle: `As on ${this.asOnDate}`,
headers: [
'Cert No',
'Party',
'Type',
'Face Value',
'Rate',
'Frequency',
'Purchase Date',
'Maturity Date',
'Accrued Interest',
'Maturity Value',
'Status'
],
widths: [
14,
28,
18,
16,
10,
14,
14,
14,
16,
16,
12
],
rows: this.holdings.map((h) => [
h.certNo,
h.party,
h.interestFreq === 'CUMULATIVE' ? 'Cumulative' : 'Payout',
h.faceValue,
h.interestRate,
h.interestFreq,
h.purchaseDate,
h.maturityDate,
this.accrued(h),
this.maturity(h),
h.status
])
}
],
`FD-Holdings-${this.asOnDate}`
);
this.toast('FD Holdings exported.');
} catch (err) {
console.error('FD Holdings export failed', err);
this.toast('Could not generate the workbook — please retry.');
} finally {
this.generating = false;
}
}
private toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
if (this.toastTimer) {
clearTimeout(this.toastTimer);
}
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
}
}
@@ -0,0 +1,216 @@
<!--
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="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">TREASURY · INVESTMENT</div>
<h1 class="pg-title">Investment Portfolio Dashboard</h1>
<p class="pg-sub">
Consolidated position across FD, Govt Security, Non-SLR, Bonds, Mutual Fund and Share holdings as on
{{ asOnDate }}.
</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
</div>
</header>
<div class="inv-kpis">
<div class="inv-kpi">
<span class="inv-kpi__label">Total Holdings</span>
<span class="inv-kpi__value">{{ totalHoldings }}</span>
<span class="inv-kpi__sub">{{ activeCount }} active · {{ maturedCount }} matured</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Total Invested (Face Value)</span>
<span class="inv-kpi__value">₹{{ totalFaceValue | number: '1.2-2' }}</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Accrued Interest / Income</span>
<span class="inv-kpi__value col-in">₹{{ totalAccruedInterest | number: '1.2-2' }}</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Projected Value at Maturity</span>
<span class="inv-kpi__value">₹{{ totalMaturityValue | number: '1.2-2' }}</span>
</div>
</div>
<div class="pg-card">
<div class="pos-section-label">Instrument-wise Break-up</div>
<table class="pg-table">
<thead>
<tr>
<th>Instrument Type</th>
<th class="num">Holdings</th>
<th class="num">Face Value</th>
<th class="num col-in">Accrued Interest</th>
<th class="num col-net">Maturity Value</th>
<th></th>
</tr>
</thead>
<tbody>
@for (s of summaries; track s.type) {
<tr>
<td>{{ s.label }}</td>
<td class="num">{{ s.count }}</td>
<td class="num">₹{{ s.faceValue | number: '1.2-2' }}</td>
<td class="num col-in">₹{{ s.accruedInterest | number: '1.2-2' }}</td>
<td class="num col-net">₹{{ s.maturityValue | number: '1.2-2' }}</td>
<td>
<a class="pg-btn pg-btn--ghost" [routerLink]="s.route">Open</a>
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td>Total</td>
<td class="num">{{ totalHoldings }}</td>
<td class="num">₹{{ totalFaceValue | number: '1.2-2' }}</td>
<td class="num">₹{{ totalAccruedInterest | number: '1.2-2' }}</td>
<td class="num">₹{{ totalMaturityValue | number: '1.2-2' }}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
<div class="pg-card">
<div class="pos-section-label">Portfolio Allocation — Target vs Actual</div>
<p class="pg-sub">
Govt Security, Non-SLR, Mutual Fund and Share targets trace to RBI's SLR floor (18% of NDTL) and Non-SLR ceiling
(≤10% of deposits) for Urban Co-operative Banks; Fixed Deposit and Bond are board-approved policy choices within
the room that framework leaves.
</p>
<table class="pg-table">
<thead>
<tr>
<th>Instrument Type</th>
<th>Target Basis</th>
<th class="num">Target %</th>
<th class="num">Actual %</th>
<th class="num">Actual Value</th>
<th class="num">Variance</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (a of allocationVsTarget; track a.type) {
<tr>
<td>{{ a.label }}</td>
<td>
<span class="inv-badge" [class.inv-badge--matured]="a.isRbiLinked">
{{ a.isRbiLinked ? 'RBI SLR / Non-SLR Norm' : 'Board Policy' }}
</span>
</td>
<td class="num">{{ a.targetPct }}%</td>
<td class="num">{{ a.actualPct }}%</td>
<td class="num">₹{{ a.actualValue | number: '1.2-2' }}</td>
<td
class="num"
[class.col-out]="a.statusLabel !== 'Within Target'"
[class.col-in]="a.statusLabel === 'Within Target'"
>
{{ a.variancePct > 0 ? '+' : '' }}{{ a.variancePct }}%
</td>
<td>
<span
class="inv-badge"
[class.inv-badge--active]="a.statusLabel === 'Within Target'"
[class.inv-badge--sold]="a.statusLabel === 'Over-Allocated'"
[class.inv-badge--written_off]="a.statusLabel === 'Under-Allocated'"
>
{{ a.statusLabel }}
</span>
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td>Total</td>
<td class="num">100%</td>
<td class="num">100%</td>
<td class="num">₹{{ totalFaceValue | number: '1.2-2' }}</td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
</div>
<div class="pg-card">
<div class="pos-section-label">Due-Date Diary — Maturing Within 90 Days</div>
@if (maturingSoon.length === 0) {
<div class="pg-empty">No holdings maturing in the next 90 days.</div>
} @else {
<div>
@for (h of maturingSoon; track h.certNo) {
<div class="inv-maturity-row">
<span>
<span class="pg-chip">{{ h.certNo }}</span>
{{ h.scriptName }} — {{ h.party }} ({{ instrumentLabel(h.instrumentType) }})
</span>
<span>
Matures {{ h.maturityDate }} ·
<span
class="inv-maturity-days"
[class.inv-maturity-days--soon]="daysLeft(h) <= 30"
[class.inv-maturity-days--ok]="daysLeft(h) > 30"
>
{{ daysLeft(h) }} days
</span>
</span>
</div>
}
</div>
}
</div>
<div class="pg-card">
<div class="pos-section-label">Quick Entry</div>
<div class="inv-nav-grid" style="padding: 16px">
<a class="inv-nav-card" routerLink="/investment/fd">
<span class="inv-nav-card__label">Fixed Deposit</span>
<span class="inv-nav-card__count">{{ summaries[0].count }}</span>
<span class="inv-nav-card__value">Purchase / Interest / Sell / Write-off</span>
</a>
<a class="inv-nav-card" routerLink="/investment/bonds">
<span class="inv-nav-card__label">Govt Security / Non-SLR / Bonds / MF</span>
<span class="inv-nav-card__count">{{
summaries[1].count + summaries[2].count + summaries[3].count + summaries[4].count
}}</span>
<span class="inv-nav-card__value">Purchase / Interest / Sell / Amortize</span>
</a>
<a class="inv-nav-card" routerLink="/investment/share">
<span class="inv-nav-card__label">Share Entry</span>
<span class="inv-nav-card__count">{{ summaries[5].count }}</span>
<span class="inv-nav-card__value">Purchase / Dividend / Sell / Write-off</span>
</a>
<a class="inv-nav-card" routerLink="/investment/interest">
<span class="inv-nav-card__label">Interest</span>
<span class="inv-nav-card__count">5</span>
<span class="inv-nav-card__value">FD / Bond Calculation &amp; Registers</span>
</a>
<a class="inv-nav-card" routerLink="/investment/masters">
<span class="inv-nav-card__label">Masters</span>
<span class="inv-nav-card__count">10</span>
<span class="inv-nav-card__value">Script / Broker / Party / Control Params</span>
</a>
<a class="inv-nav-card" routerLink="/investment/reports">
<span class="inv-nav-card__label">Reports</span>
<span class="inv-nav-card__count">20+</span>
<span class="inv-nav-card__value">Balance / Ledger / Maturity / Portfolio</span>
</a>
</div>
</div>
</div>
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
@@ -0,0 +1,249 @@
/**
* 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 } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import {
ALL_HOLDINGS,
ALLOCATION_TARGET_IS_RBI_LINKED,
INSTRUMENT_LABELS,
InstrumentType,
InvestmentHolding,
TARGET_ALLOCATION_PCT,
accruedInterest,
daysToMaturity,
maturityAmount
} from '../investment-data';
import { exportWorkbook } from '../investment-export';
interface InstrumentSummary {
type: InstrumentType;
label: string;
route: string;
count: number;
faceValue: number;
accruedInterest: number;
maturityValue: number;
}
interface AllocationRow {
type: InstrumentType;
label: string;
targetPct: number;
actualPct: number;
actualValue: number;
variancePct: number;
statusLabel: 'Within Target' | 'Over-Allocated' | 'Under-Allocated';
/** True when the target itself traces to an RBI SLR/Non-SLR rule rather than being a
* board-only policy choice — see TARGET_ALLOCATION_PCT's doc comment in investment-data.ts. */
isRbiLinked: boolean;
}
/** Cross-instrument portfolio dashboard — every figure is derived from ALL_HOLDINGS, never
* hand-duplicated, so it can never drift from the per-instrument screens or Reports. */
@Component({
selector: 'mifosx-investment-dashboard',
templateUrl: './investment-dashboard.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../investment-shared.scss'
],
standalone: true,
imports: [
CommonModule,
RouterLink
]
})
export class InvestmentDashboardComponent {
asOnDate = '';
generating = false;
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
private readonly types: Array<{ type: InstrumentType; route: string }> = [
{ type: 'FD', route: '/investment/fd' },
{ type: 'GOVT_SECURITY', route: '/investment/bonds' },
{ type: 'NON_SLR', route: '/investment/bonds' },
{ type: 'BOND', route: '/investment/bonds' },
{ type: 'MUTUAL_FUND', route: '/investment/bonds' },
{ type: 'SHARE', route: '/investment/share' }
];
constructor() {
const d = new Date();
this.asOnDate = `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
}
private holdingsOf(type: InstrumentType): InvestmentHolding[] {
return ALL_HOLDINGS.filter((h) => h.instrumentType === type);
}
get summaries(): InstrumentSummary[] {
return this.types.map(({ type, route }) => {
const holdings = this.holdingsOf(type);
return {
type,
label: INSTRUMENT_LABELS[type],
route,
count: holdings.length,
faceValue: holdings.reduce((s, h) => s + h.faceValue, 0),
accruedInterest: holdings.reduce((s, h) => s + accruedInterest(h, this.asOnDate), 0),
maturityValue: holdings.reduce((s, h) => s + maturityAmount(h), 0)
};
});
}
/** Board-approved policy target % per instrument type vs the portfolio's actual allocation
* (by invested/face value) — a ±2 percentage-point band is treated as "Within Target" since
* a treasury desk can't rebalance to the exact decimal every day. */
get allocationVsTarget(): AllocationRow[] {
const total = this.totalFaceValue;
return this.types.map(({ type }) => {
const value = this.holdingsOf(type).reduce((s, h) => s + h.faceValue, 0);
const actualPct = total ? (value / total) * 100 : 0;
const targetPct = TARGET_ALLOCATION_PCT[type];
const variancePct = +(actualPct - targetPct).toFixed(1);
const statusLabel: AllocationRow['statusLabel'] =
Math.abs(variancePct) <= 2 ? 'Within Target' : variancePct > 0 ? 'Over-Allocated' : 'Under-Allocated';
return {
type,
label: INSTRUMENT_LABELS[type],
targetPct,
actualPct: +actualPct.toFixed(1),
actualValue: value,
variancePct,
statusLabel,
isRbiLinked: ALLOCATION_TARGET_IS_RBI_LINKED[type]
};
});
}
get totalHoldings(): number {
return ALL_HOLDINGS.length;
}
get totalFaceValue(): number {
return ALL_HOLDINGS.reduce((s, h) => s + h.faceValue, 0);
}
get totalAccruedInterest(): number {
return ALL_HOLDINGS.reduce((s, h) => s + accruedInterest(h, this.asOnDate), 0);
}
get totalMaturityValue(): number {
return ALL_HOLDINGS.reduce((s, h) => s + maturityAmount(h), 0);
}
get activeCount(): number {
return ALL_HOLDINGS.filter((h) => h.status === 'ACTIVE').length;
}
get maturedCount(): number {
return ALL_HOLDINGS.filter((h) => h.status === 'MATURED').length;
}
/** Holdings maturing within the next 90 days — a "due-date diary" style widget. */
get maturingSoon(): InvestmentHolding[] {
return ALL_HOLDINGS.filter((h) => h.status === 'ACTIVE' && daysToMaturity(h, this.asOnDate) <= 90).sort(
(a, b) => daysToMaturity(a, this.asOnDate) - daysToMaturity(b, this.asOnDate)
);
}
daysLeft(h: InvestmentHolding): number {
return daysToMaturity(h, this.asOnDate);
}
instrumentLabel(type: InstrumentType): string {
return INSTRUMENT_LABELS[type];
}
async exportExcel(): Promise<void> {
this.generating = true;
try {
await exportWorkbook(
[
{
sheetName: 'Instrument Breakup',
title: 'Investment Portfolio — Instrument-wise Break-up',
subtitle: `As on ${this.asOnDate}`,
headers: [
'Instrument Type',
'Holdings',
'Face Value',
'Accrued Interest',
'Maturity Value'
],
widths: [
26,
12,
18,
18,
18
],
rows: this.summaries.map((s) => [
s.label,
s.count,
s.faceValue,
s.accruedInterest,
s.maturityValue
])
},
{
sheetName: 'Portfolio Allocation',
title: 'Portfolio Allocation — Target vs Actual',
subtitle: `As on ${this.asOnDate}`,
headers: [
'Instrument Type',
'Target Basis',
'Target %',
'Actual %',
'Actual Value',
'Variance %',
'Status'
],
widths: [
26,
20,
12,
12,
18,
12,
18
],
rows: this.allocationVsTarget.map((a) => [
a.label,
a.isRbiLinked ? 'RBI SLR / Non-SLR Norm' : 'Board Policy',
a.targetPct,
a.actualPct,
a.actualValue,
a.variancePct,
a.statusLabel
])
}
],
`Investment-Dashboard-${this.asOnDate}`
);
this.toast('Investment Dashboard exported.');
} catch (err) {
console.error('Investment Dashboard export failed', err);
this.toast('Could not generate the workbook — please retry.');
} finally {
this.generating = false;
}
}
private toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
if (this.toastTimer) {
clearTimeout(this.toastTimer);
}
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
}
}
File diff suppressed because it is too large Load Diff
+89
View File
@@ -0,0 +1,89 @@
/**
* 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/.
*/
// Shared "Export to Excel" builder for every listing/report screen in the Investment module
// (Dashboard, Reports, Interest, Holdings, Masters) — centralised so the ExcelJS boilerplate
// (title/meta/header rows, number formatting, safe sheet names, Blob download) is written once
// and every screen's export always behaves and looks the same.
import * as ExcelJS from 'exceljs';
export interface ExportSheetSpec {
/** Worksheet tab name — sanitised for Excel's forbidden characters (\ / ? * [ ]) and length. */
sheetName: string;
/** Bold title row, merged across all columns. */
title: string;
/** Optional italic subtitle row (e.g. scope/date), merged across all columns. */
subtitle?: string;
headers: string[];
rows: (string | number | null)[][];
widths?: number[];
}
/** Excel worksheet names can't contain \ / ? * [ ] or exceed 31 chars. */
function safeSheetName(name: string): string {
return name.replace(/[\\/?*[\]]/g, '-').slice(0, 31);
}
function buildSheet(wb: ExcelJS.Workbook, spec: ExportSheetSpec): void {
const sheet = wb.addWorksheet(safeSheetName(spec.sheetName));
sheet.columns = (spec.widths || spec.headers.map(() => 18)).map((w) => ({ width: w }));
const title = sheet.addRow([spec.title]);
sheet.mergeCells(title.number, 1, title.number, spec.headers.length);
title.font = { bold: true, size: 14, color: { argb: 'FFFFFFFF' } };
title.alignment = { horizontal: 'center', vertical: 'middle' };
title.height = 24;
title.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0F4F3C' } };
if (spec.subtitle) {
const meta = sheet.addRow([spec.subtitle]);
sheet.mergeCells(meta.number, 1, meta.number, spec.headers.length);
meta.font = { italic: true, size: 10 };
}
sheet.addRow([]);
const head = sheet.addRow(spec.headers);
head.font = { bold: true };
head.alignment = { wrapText: true, horizontal: 'center' };
head.eachCell((c) => {
c.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } };
c.border = { bottom: { style: 'thin' } };
});
for (const r of spec.rows) {
const row = sheet.addRow(r);
r.forEach((v, idx) => {
if (typeof v === 'number') {
row.getCell(idx + 1).numFmt = '#,##0.00';
}
});
}
sheet.views = [{ state: 'frozen', ySplit: spec.subtitle ? 4 : 3 }];
}
/** Builds a (possibly multi-sheet) workbook and triggers a browser download. */
export async function exportWorkbook(sheets: ExportSheetSpec[], fileName: string): Promise<void> {
const wb = new ExcelJS.Workbook();
wb.creator = 'CBSNexora';
wb.created = new Date();
sheets.forEach((s) => buildSheet(wb, s));
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${fileName.replace(/[^\w]+/g, '-')}.xlsx`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
@@ -0,0 +1,609 @@
<!--
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="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">TREASURY · INVESTMENT · INTEREST</div>
<h1 class="pg-title">Investment Interest</h1>
<p class="pg-sub">
Interest Receivable, FD and Govt./Bond interest calculation-and-post batch runs, Interest Receive, plus the
Government Bond Interest, FD Interest and FD registers.
</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
</div>
</header>
<div class="cctr-tabs">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'receivable'"
(click)="setTab('receivable')"
>
Interest Receivable
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'fdCalc'"
(click)="setTab('fdCalc')"
>
01 FD Interest Calculation &amp; Post
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'bondCalc'"
(click)="setTab('bondCalc')"
>
02 Govt./Bond Int. Calculation &amp; Post
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'receive'"
(click)="setTab('receive')"
>
Interest Receive
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'bondIntRegister'"
(click)="setTab('bondIntRegister')"
>
03 Government Bond Int Register
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'fdIntRegister'"
(click)="setTab('fdIntRegister')"
>
04 FD Interest Register
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'fdRegister'"
(click)="setTab('fdRegister')"
>
05 FD Register
</button>
</div>
<!-- INTEREST RECEIVABLE -->
@if (activeTab === 'receivable') {
<div class="pg-card">
<div class="pos-section-label">
Interest Receivable — Posted but Not Yet Collected (₹{{ totalReceivable | number: '1.2-2' }})
</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script / Party</th>
<th>Type</th>
<th>Status</th>
<th class="num">Interest Posted</th>
<th class="num">Interest Received</th>
<th class="num col-in">Receivable Balance</th>
</tr>
</thead>
<tbody>
@for (h of receivableRows; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName || h.party }}</td>
<td>{{ instrumentLabel(h.instrumentType) }}</td>
<td>
<span [class]="provisionBadgeClass(h)">{{ provisionLabel(h) }}</span>
</td>
<td class="num">₹{{ posted(h) | number: '1.2-2' }}</td>
<td class="num">₹{{ received(h) | number: '1.2-2' }}</td>
<td class="num col-in">₹{{ receivableBalance(h) | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="pg-empty">No outstanding interest receivable.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="6">Total Receivable</td>
<td class="num">₹{{ totalReceivable | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
@if (suspenseRows.length > 0) {
<div class="pg-card">
<div class="pos-section-label">
Non-Performing Investments — Interest Held in Suspense (₹{{ totalSuspense | number: '1.2-2' }})
</div>
<p class="pg-sub" style="padding: 0 16px">
These holdings are classified Non-Performing (Sub-Standard/Doubtful/Loss). Per RBI income-recognition norms,
interest is not posted as an accrual receivable — it accrues in memo only, and is recognised as income solely
on actual cash receipt via the Interest Receive tab.
</p>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script / Party</th>
<th>Type</th>
<th>Status</th>
<th class="num">Provision Required</th>
<th class="num col-out">Interest in Suspense</th>
</tr>
</thead>
<tbody>
@for (h of suspenseRows; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName || h.party }}</td>
<td>{{ instrumentLabel(h.instrumentType) }}</td>
<td>
<span [class]="provisionBadgeClass(h)">{{ provisionLabel(h) }}</span>
</td>
<td class="num">₹{{ provisionRequired(h) | number: '1.2-2' }}</td>
<td class="num col-out">₹{{ suspense(h) | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="5">Total Suspense</td>
<td class="num">₹{{ totalSuspense | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
}
<!-- 01 FD INTEREST CALCULATION -->
@if (activeTab === 'fdCalc') {
<div class="pg-card">
<div class="pos-section-label">Investment - FD Interest Calculation &amp; Post</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>From Date</span><mifosx-date-ddmmyyyy [(ngModel)]="fdCalcFromDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>To Date</span><mifosx-date-ddmmyyyy [(ngModel)]="fdCalcToDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field">
<span>Certificate No</span>
<select [(ngModel)]="fdCalcCertNo">
<option value="">-- All Certificates --</option>
@for (h of fdHoldings; track h.certNo) {
<option [value]="h.certNo">{{ h.certNo }} — {{ h.party }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>ALL</span><input type="checkbox" [(ngModel)]="fdCalcAll" style="height: 18px; width: 18px"
/></label>
<label class="inv-form-field">
<span>Post To (Interest Income GL)</span>
<select [(ngModel)]="fdCalcGlCode">
@for (g of interestIncomeGlOptions; track g.glCode) {
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Total Interest to Post</span
><input type="text" [value]="'₹' + (fdCalcTotal | number: '1.2-2')" readonly
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="processFdInterest()">Calculate &amp; Post</button>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Party</th>
<th class="num">Face Value</th>
<th class="num">Rate</th>
<th>Frequency</th>
<th class="num col-in">Interest</th>
</tr>
</thead>
<tbody>
@for (h of fdCalcTargets; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.party }}</td>
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
<td class="num">{{ h.interestRate }}%</td>
<td>{{ h.interestFreq }}</td>
<td class="num col-in">₹{{ fdInterest(h) | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="pg-empty">No FD certificates match the filter.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="5">Total</td>
<td class="num">₹{{ fdCalcTotal | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
@if (fdCalcExcludedNpi.length > 0) {
<p class="pg-sub" style="padding: 12px 16px 16px">
{{ fdCalcExcludedNpi.length }} certificate(s) excluded — classified Non-Performing, interest is not posted on
accrual basis (see Interest Receivable tab for suspense amount).
</p>
}
</div>
}
<!-- 02 GOVT./BOND INT. CALCULATION -->
@if (activeTab === 'bondCalc') {
<div class="pg-card">
<div class="pos-section-label">Investment - Govt./Bond Interest Calculation &amp; Post</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>From Date</span><mifosx-date-ddmmyyyy [(ngModel)]="bondCalcFromDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>To Date</span><mifosx-date-ddmmyyyy [(ngModel)]="bondCalcToDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field">
<span>Type</span>
<select [(ngModel)]="bondCalcType" (ngModelChange)="onBondCalcTypeChange()">
<option value="">-- All Types --</option>
<option value="GOVT_SECURITY">GOVT SECURITY</option>
<option value="NON_SLR">NON SLR</option>
<option value="BOND">BOND</option>
</select>
</label>
<label class="inv-form-field">
<span>Script</span>
<select [(ngModel)]="bondCalcScript">
<option value="">-- All Scripts --</option>
@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)]="bondCalcDealNo" /></label>
<label class="inv-form-field"
><span>ALL</span><input type="checkbox" [(ngModel)]="bondCalcAll" style="height: 18px; width: 18px"
/></label>
<label class="inv-form-field">
<span>Post To (Interest Income GL)</span>
<select [(ngModel)]="bondCalcGlCode">
@for (g of interestIncomeGlOptions; track g.glCode) {
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Total Interest to Post</span
><input type="text" [value]="'₹' + (bondCalcTotal | number: '1.2-2')" readonly
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="processBondInterest()">
Calculate &amp; Post
</button>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script</th>
<th>Type</th>
<th class="num">Face Value</th>
<th class="num">Rate</th>
<th>Frequency</th>
<th class="num col-in">Interest</th>
</tr>
</thead>
<tbody>
@for (h of bondCalcTargets; 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.faceValue | number: '1.2-2' }}</td>
<td class="num">{{ h.interestRate }}%</td>
<td>{{ h.interestFreq }}</td>
<td class="num col-in">₹{{ bondInterest(h) | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="pg-empty">No holdings match the filter.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="6">Total</td>
<td class="num">₹{{ bondCalcTotal | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
@if (bondCalcExcludedNpi.length > 0) {
<p class="pg-sub" style="padding: 12px 16px 16px">
{{ bondCalcExcludedNpi.length }} holding(s) excluded — classified Non-Performing, interest is not posted on
accrual basis (see Interest Receivable tab for suspense amount).
</p>
}
</div>
}
<!-- INTEREST RECEIVE -->
@if (activeTab === 'receive') {
<div class="pg-card">
<div class="pos-section-label">Interest Receive — Record Actual Cash Receipt</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Receive Into (Cash/Bank GL)</span>
<select [(ngModel)]="receiveGlCode">
@for (g of cashBankGlOptions; track g.glCode) {
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
}
</select>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script / Party</th>
<th>Type</th>
<th>Status</th>
<th class="num">Outstanding</th>
<th class="num">Amount to Receive</th>
<th></th>
</tr>
</thead>
<tbody>
@for (h of receiveTargets; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName || h.party }}</td>
<td>{{ instrumentLabel(h.instrumentType) }}</td>
<td>
<span [class]="provisionBadgeClass(h)">{{ provisionLabel(h) }}</span>
</td>
<td class="num">₹{{ outstandingFor(h) | number: '1.2-2' }}</td>
<td class="num">
<input
type="number"
[(ngModel)]="receiveDrafts[h.certNo]"
[max]="outstandingFor(h)"
style="width: 120px; text-align: right"
/>
</td>
<td>
<button type="button" class="pg-btn pg-btn--ghost" (click)="receiveInterest(h)">Receive</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="pg-empty">No interest outstanding for collection.</td>
</tr>
}
</tbody>
</table>
</div>
<div class="pg-card">
<div class="pos-section-label">Interest Received Log</div>
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>Voucher No</th>
<th>Cert No</th>
<th>Particulars</th>
<th>GL Credited</th>
<th class="num col-in">Amount Received</th>
</tr>
</thead>
<tbody>
@for (t of receivedLog; track t.voucherNo) {
<tr>
<td>{{ t.txnDate }}</td>
<td>
<span class="pg-chip">{{ t.voucherNo }}</span>
</td>
<td>{{ t.certNo }}</td>
<td>{{ t.particulars }}</td>
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="pg-empty">No interest received yet.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="5">Total Received</td>
<td class="num">₹{{ receivedLogTotal | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
<!-- 03 GOVERNMENT BOND INT REGISTER -->
@if (activeTab === 'bondIntRegister') {
<div class="pg-card">
<div class="pos-section-label">Government Bond Interest Register</div>
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>Voucher No</th>
<th>Cert No</th>
<th>Instrument</th>
<th>Particulars</th>
<th>GL Posted</th>
<th class="num col-in">Interest Amount</th>
</tr>
</thead>
<tbody>
@for (t of bondIntRegister; track t.voucherNo) {
<tr>
<td>{{ t.txnDate }}</td>
<td>
<span class="pg-chip">{{ t.voucherNo }}</span>
</td>
<td>{{ t.certNo }}</td>
<td>{{ instrumentLabel(t.instrumentType) }}</td>
<td>{{ t.particulars }}</td>
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="pg-empty">No bond interest entries recorded yet.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="6">Total</td>
<td class="num">₹{{ bondIntRegisterTotal | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
<!-- 04 FD INTEREST REGISTER -->
@if (activeTab === 'fdIntRegister') {
<div class="pg-card">
<div class="pos-section-label">FD Interest Register</div>
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>Voucher No</th>
<th>Cert No</th>
<th>Particulars</th>
<th>GL Posted</th>
<th class="num col-in">Interest Amount</th>
</tr>
</thead>
<tbody>
@for (t of fdIntRegister; track t.voucherNo) {
<tr>
<td>{{ t.txnDate }}</td>
<td>
<span class="pg-chip">{{ t.voucherNo }}</span>
</td>
<td>{{ t.certNo }}</td>
<td>{{ t.particulars }}</td>
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="pg-empty">No FD interest entries recorded yet.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="5">Total</td>
<td class="num">₹{{ fdIntRegisterTotal | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
<!-- 05 FD REGISTER -->
@if (activeTab === 'fdRegister') {
<div class="pg-card">
<div class="pos-section-label">FD Register</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Party</th>
<th>Sub Type</th>
<th class="num">Face Value</th>
<th class="num">Rate</th>
<th>Frequency</th>
<th>Deposit Date</th>
<th>Maturity Date</th>
<th class="num">Days Left</th>
<th class="num col-in">Accrued Interest</th>
<th class="num col-net">Maturity Value</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (h of fdRegister; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.party }}</td>
<td>{{ h.subType }}</td>
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
<td class="num">{{ h.interestRate }}%</td>
<td>{{ h.interestFreq }}</td>
<td>{{ h.purchaseDate }}</td>
<td>{{ h.maturityDate }}</td>
<td class="num">{{ daysLeft(h) }}</td>
<td class="num col-in">₹{{ accrued(h) | number: '1.2-2' }}</td>
<td class="num col-net">₹{{ maturity(h) | number: '1.2-2' }}</td>
<td>
<span [class]="statusClass(h)">{{ h.status }}</span>
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="3">Total</td>
<td class="num">₹{{ fdRegisterFaceTotal | number: '1.2-2' }}</td>
<td colspan="5"></td>
<td class="num">₹{{ fdRegisterAccruedTotal | number: '1.2-2' }}</td>
<td class="num">₹{{ fdRegisterMaturityTotal | number: '1.2-2' }}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
}
</div>
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
@@ -0,0 +1,760 @@
/**
* 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 } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import {
FD_HOLDINGS,
GOVT_SECURITY_HOLDINGS,
NON_SLR_HOLDINGS,
BOND_HOLDINGS,
ALL_TXNS,
SCRIPT_MASTER,
INTEREST_INCOME_GL_MASTER,
INTEREST_INCOME_GL_BY_TYPE,
CASH_BANK_GL_MASTER,
InvestmentHolding,
InvestmentTxn,
InstrumentType,
InvestmentProvisionStatus,
INSTRUMENT_LABELS,
PROVISION_STATUS_LABELS,
accruedInterest,
accruedInterestBetween,
addPeriod,
daysToMaturity,
maturityAmount,
todayDDMMYYYY,
isNpi,
canAccrueInterest,
interestReceivableBalance,
suspenseInterest,
provisionRequiredInv
} from '../investment-data';
import { exportWorkbook, ExportSheetSpec } from '../investment-export';
type InterestTab =
| 'receivable'
| 'fdCalc'
| 'bondCalc'
| 'receive'
| 'bondIntRegister'
| 'fdIntRegister'
| 'fdRegister';
/**
* The legacy "Interest" top-menu for Investment — plus the full accrual lifecycle: Interest
* Receivable, 01 FD Interest Calculation, 02 Govt./Bond Int. Calculation, Interest Receive,
* 03 Government Bond Int Register, 04 FD Interest Register, 05 FD Register.
*
* Interest figures are derived from the same holdings used by the Entry screens and Reports,
* so a register here always agrees with what a Purchase/Interest entry recorded.
*
* Provisioning: a holding classified Non-Performing (Sub-Standard/Doubtful/Loss) can never have
* interest calculated/posted on accrual basis (canAccrueInterest() blocks it) — only actual cash
* receipts are recognised for it, straight through Interest Receive, matching RBI's income
* recognition norms for Non-Performing Investments.
*/
@Component({
selector: 'mifosx-investment-interest',
templateUrl: './investment-interest.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../investment-shared.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
DateDdmmyyyyPickerComponent
]
})
export class InvestmentInterestComponent {
activeTab: InterestTab = 'receivable';
asOnDate = todayDDMMYYYY();
fdHoldings: InvestmentHolding[] = [...FD_HOLDINGS];
bondHoldings: InvestmentHolding[] = [
...GOVT_SECURITY_HOLDINGS,
...NON_SLR_HOLDINGS,
...BOND_HOLDINGS
];
txns: InvestmentTxn[] = [...ALL_TXNS];
readonly scriptOptions = SCRIPT_MASTER;
readonly interestIncomeGlOptions = INTEREST_INCOME_GL_MASTER;
readonly cashBankGlOptions = CASH_BANK_GL_MASTER;
/** Draft "amount to receive" per certificate, keyed by certNo, for the Interest Receive tab. */
receiveDrafts: Record<string, number | null> = {};
generating = false;
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
// 01 FD Interest Calculation — posts interest accrued strictly within [fromDate, toDate]
fdCalcFromDate = addPeriod(this.asOnDate, 0, -3);
fdCalcToDate = this.asOnDate;
fdCalcCertNo = '';
fdCalcAll = false;
fdCalcGlCode = INTEREST_INCOME_GL_BY_TYPE.FD;
// 02 Govt./Bond Int. Calculation — posts interest accrued strictly within [fromDate, toDate]
bondCalcFromDate = addPeriod(this.asOnDate, 0, -3);
bondCalcToDate = this.asOnDate;
bondCalcType: InstrumentType | '' = '';
bondCalcScript = '';
bondCalcDealNo = '';
bondCalcAll = false;
bondCalcGlCode = INTEREST_INCOME_GL_BY_TYPE.GOVT_SECURITY;
// Interest Receive — Cash/Bank GL credited for the day's collection voucher
receiveGlCode = CASH_BANK_GL_MASTER[0].glCode;
// Register filters
registerFromDate = '';
registerToDate = '';
setTab(tab: InterestTab): void {
this.activeTab = tab;
}
instrumentLabel(type: InstrumentType): string {
return INSTRUMENT_LABELS[type];
}
statusClass(h: InvestmentHolding): string {
return `inv-badge inv-badge--${h.status.toLowerCase()}`;
}
maturity(h: InvestmentHolding): number {
return maturityAmount(h);
}
daysLeft(h: InvestmentHolding): number {
return daysToMaturity(h, this.asOnDate);
}
get allInterestHoldings(): InvestmentHolding[] {
return [
...this.fdHoldings,
...this.bondHoldings
].filter((h) => h.status === 'ACTIVE');
}
isNpiHolding(h: InvestmentHolding): boolean {
return isNpi(h);
}
provisionLabel(h: InvestmentHolding): string {
return PROVISION_STATUS_LABELS[h.provisionStatus ?? 'STANDARD'];
}
provisionBadgeClass(h: InvestmentHolding): string {
const status: InvestmentProvisionStatus = h.provisionStatus ?? 'STANDARD';
return `inv-badge inv-badge--${status.toLowerCase()}`;
}
posted(h: InvestmentHolding): number {
return h.interestPosted ?? 0;
}
received(h: InvestmentHolding): number {
return h.interestReceived ?? 0;
}
receivableBalance(h: InvestmentHolding): number {
return interestReceivableBalance(h);
}
suspense(h: InvestmentHolding): number {
return suspenseInterest(h, this.asOnDate);
}
provisionRequired(h: InvestmentHolding): number {
return provisionRequiredInv(h);
}
// -------------------- 01 FD Interest Calculation --------------------
get fdCalcTargets(): InvestmentHolding[] {
const base = this.fdHoldings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h));
if (this.fdCalcAll) {
return base;
}
return base.filter((h) => !this.fdCalcCertNo || h.certNo === this.fdCalcCertNo);
}
/** NPI FD certificates excluded from calculation/posting — interest recognised on cash basis
* only, via the Interest Receive tab. */
get fdCalcExcludedNpi(): InvestmentHolding[] {
return this.fdHoldings.filter((h) => h.status === 'ACTIVE' && isNpi(h));
}
fdInterest(h: InvestmentHolding): number {
return accruedInterestBetween(h, this.fdCalcFromDate || h.purchaseDate, this.fdCalcToDate || this.asOnDate);
}
get fdCalcTotal(): number {
return this.fdCalcTargets.reduce((s, h) => s + this.fdInterest(h), 0);
}
glName(glCode: string): string {
return (
this.interestIncomeGlOptions.find((g) => g.glCode === glCode)?.glName ??
this.cashBankGlOptions.find((g) => g.glCode === glCode)?.glName ??
glCode
);
}
processFdInterest(): void {
const targets = this.fdCalcTargets.filter((h) => this.fdInterest(h) > 0);
if (targets.length === 0) {
this.toast('No FD holdings have interest due to be posted for this period.');
return;
}
const toDate = this.fdCalcToDate || this.asOnDate;
const fromDate = this.fdCalcFromDate || toDate;
const glCode = this.fdCalcGlCode;
const glName = this.glName(glCode);
const newTxns: InvestmentTxn[] = targets.map((h, i) => {
const amt = this.fdInterest(h);
h.interestPosted = this.posted(h) + amt;
h.interestPostedTillDate = toDate;
return {
certNo: h.certNo,
instrumentType: 'FD',
txnType: 'INTEREST',
voucherNo: `INV/FD-INT/${toDate.replace(/-/g, '')}-${i + 1}`,
txnDate: toDate,
amount: amt,
particulars: `FD interest posted (accrual) for period ${fromDate} to ${toDate} - ${h.party}`,
glCode,
glName
};
});
this.fdCalcFromDate = toDate;
const total = newTxns.reduce((s, t) => s + t.amount, 0);
this.txns = [
...newTxns,
...this.txns
];
this.toast(
`FD interest posted for ${targets.length} certificate(s) to ${glCode} - ${glName}. Total ₹${total.toLocaleString('en-IN')}.`
);
}
// -------------------- 02 Govt./Bond Int. Calculation --------------------
get bondCalcTargets(): InvestmentHolding[] {
const base = this.bondHoldings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h));
if (this.bondCalcAll) {
return base;
}
return base.filter((h) => {
if (this.bondCalcType && h.instrumentType !== this.bondCalcType) return false;
if (this.bondCalcScript && h.scriptCode !== this.bondCalcScript) return false;
if (this.bondCalcDealNo && h.certNo !== this.bondCalcDealNo.trim()) return false;
return true;
});
}
/** NPI Govt/Bond holdings excluded from calculation/posting — interest recognised on cash
* basis only, via the Interest Receive tab. */
get bondCalcExcludedNpi(): InvestmentHolding[] {
return this.bondHoldings.filter((h) => h.status === 'ACTIVE' && isNpi(h));
}
bondInterest(h: InvestmentHolding): number {
return accruedInterestBetween(h, this.bondCalcFromDate || h.purchaseDate, this.bondCalcToDate || this.asOnDate);
}
get bondCalcTotal(): number {
return this.bondCalcTargets.reduce((s, h) => s + this.bondInterest(h), 0);
}
/** Re-suggests the Interest Income GL when the Type filter changes — still overridable. */
onBondCalcTypeChange(): void {
if (this.bondCalcType) {
this.bondCalcGlCode = INTEREST_INCOME_GL_BY_TYPE[this.bondCalcType];
}
}
processBondInterest(): void {
const targets = this.bondCalcTargets.filter((h) => this.bondInterest(h) > 0);
if (targets.length === 0) {
this.toast('No Govt/Bond holdings have interest due to be posted for this period.');
return;
}
const toDate = this.bondCalcToDate || this.asOnDate;
const fromDate = this.bondCalcFromDate || toDate;
const glCode = this.bondCalcGlCode;
const glName = this.glName(glCode);
const newTxns: InvestmentTxn[] = targets.map((h, i) => {
const amt = this.bondInterest(h);
h.interestPosted = this.posted(h) + amt;
h.interestPostedTillDate = toDate;
return {
certNo: h.certNo,
instrumentType: h.instrumentType,
txnType: 'INTEREST',
voucherNo: `INV/BOND-INT/${toDate.replace(/-/g, '')}-${i + 1}`,
txnDate: toDate,
amount: amt,
particulars: `Coupon/interest posted (accrual) for period ${fromDate} to ${toDate} - ${h.scriptName}`,
glCode,
glName
};
});
const total = newTxns.reduce((s, t) => s + t.amount, 0);
this.txns = [
...newTxns,
...this.txns
];
this.bondCalcFromDate = toDate;
this.toast(
`Interest posted for ${targets.length} holding(s) to ${glCode} - ${glName}. Total ₹${total.toLocaleString('en-IN')}.`
);
}
// -------------------- Interest Receivable (all interest-bearing types) --------------------
/** Standard holdings with a posted-but-uncollected balance — a genuine "Interest Receivable"
* GL asset. */
get receivableRows(): InvestmentHolding[] {
return this.allInterestHoldings.filter((h) => canAccrueInterest(h) && this.receivableBalance(h) > 0);
}
/** NPI holdings with interest still accruing in memo/suspense — not a GL asset, shown
* separately so the treasury desk can see what cash-basis income is outstanding. */
get suspenseRows(): InvestmentHolding[] {
return this.allInterestHoldings.filter((h) => isNpi(h) && this.suspense(h) > 0);
}
get totalReceivable(): number {
return this.receivableRows.reduce((s, h) => s + this.receivableBalance(h), 0);
}
get totalSuspense(): number {
return this.suspenseRows.reduce((s, h) => s + this.suspense(h), 0);
}
// -------------------- Interest Receive (cash receipt, all interest-bearing types) --------------------
/** Everything with money outstanding to collect — Standard holdings against their posted
* receivable, and NPI holdings against their notional suspense (cash-basis recognition). */
get receiveTargets(): InvestmentHolding[] {
return this.allInterestHoldings.filter((h) =>
canAccrueInterest(h) ? this.receivableBalance(h) > 0 : this.suspense(h) > 0
);
}
outstandingFor(h: InvestmentHolding): number {
return canAccrueInterest(h) ? this.receivableBalance(h) : this.suspense(h);
}
get receivedLog(): InvestmentTxn[] {
return this.txns.filter((t) => t.txnType === 'INTEREST_RECEIVED');
}
get receivedLogTotal(): number {
return this.receivedLog.reduce((s, t) => s + t.amount, 0);
}
receiveInterest(h: InvestmentHolding): void {
const draft = this.receiveDrafts[h.certNo];
if (!draft || draft <= 0) {
this.toast('Enter a valid amount to receive.');
return;
}
let amt = draft;
let particulars: string;
if (canAccrueInterest(h)) {
amt = Math.min(draft, this.receivableBalance(h));
h.interestReceived = this.received(h) + amt;
particulars = `Interest received against posted receivable - ${h.scriptName || h.party}`;
} else {
h.interestReceived = this.received(h) + amt;
particulars = `Interest received on cash basis (${this.provisionLabel(h)}) - ${h.scriptName || h.party}`;
}
const glCode = this.receiveGlCode;
const glName = this.glName(glCode);
this.txns = [
{
certNo: h.certNo,
instrumentType: h.instrumentType,
txnType: 'INTEREST_RECEIVED',
voucherNo: `INV/INT-RCV/${h.certNo}-${this.receivedLog.length + 1}`,
txnDate: this.asOnDate,
amount: amt,
particulars: `${particulars} (Dr. ${glCode} - ${glName})`,
glCode,
glName
},
...this.txns
];
this.toast(`₹${amt.toLocaleString('en-IN')} interest received into ${glCode} - ${glName} for ${h.certNo}.`);
delete this.receiveDrafts[h.certNo];
}
// -------------------- 03 Government Bond Int Register --------------------
get bondIntRegister(): InvestmentTxn[] {
return this.txns.filter(
(t) => t.txnType === 'INTEREST' && ([
'GOVT_SECURITY',
'NON_SLR',
'BOND'
] as InstrumentType[]).includes(t.instrumentType)
);
}
get bondIntRegisterTotal(): number {
return this.bondIntRegister.reduce((s, t) => s + t.amount, 0);
}
// -------------------- 04 FD Interest Register --------------------
get fdIntRegister(): InvestmentTxn[] {
return this.txns.filter((t) => t.txnType === 'INTEREST' && t.instrumentType === 'FD');
}
get fdIntRegisterTotal(): number {
return this.fdIntRegister.reduce((s, t) => s + t.amount, 0);
}
// -------------------- 05 FD Register --------------------
get fdRegister(): InvestmentHolding[] {
return this.fdHoldings;
}
get fdRegisterFaceTotal(): number {
return this.fdRegister.reduce((s, h) => s + h.faceValue, 0);
}
get fdRegisterMaturityTotal(): number {
return this.fdRegister.reduce((s, h) => s + this.maturity(h), 0);
}
accrued(h: InvestmentHolding): number {
return accruedInterest(h, this.asOnDate);
}
get fdRegisterAccruedTotal(): number {
return this.fdRegister.reduce((s, h) => s + this.accrued(h), 0);
}
// -------------------- Export to Excel --------------------
private txnExportRow(t: InvestmentTxn): (string | number | null)[] {
return [
t.txnDate,
t.voucherNo,
t.certNo,
t.instrumentType,
t.particulars,
t.amount,
t.glCode ?? '',
t.glName ?? ''
];
}
private exportSheets(): ExportSheetSpec[] {
const subtitle = `As on ${this.asOnDate}`;
const txnHeaders = [
'Date',
'Voucher No',
'Cert No',
'Type',
'Particulars',
'Amount',
'GL Code',
'GL Name'
];
const txnWidths = [
14,
22,
14,
14,
42,
16,
12,
28
];
const one = (
sheetName: string,
title: string,
headers: string[],
widths: number[],
rows: (string | number | null)[][]
): ExportSheetSpec[] => [
{ sheetName, title, subtitle, headers, widths, rows }];
switch (this.activeTab) {
case 'receivable': {
const sheets: ExportSheetSpec[] = [
{
sheetName: 'Interest Receivable',
title: 'Interest Receivable — Posted but Not Yet Collected',
subtitle,
headers: [
'Cert No',
'Script / Party',
'Type',
'Status',
'Interest Posted',
'Interest Received',
'Receivable Balance'
],
widths: [
14,
30,
16,
16,
16,
16,
18
],
rows: this.receivableRows.map((h) => [
h.certNo,
h.scriptName || h.party,
this.instrumentLabel(h.instrumentType),
this.provisionLabel(h),
this.posted(h),
this.received(h),
this.receivableBalance(h)
])
}
];
if (this.suspenseRows.length > 0) {
sheets.push({
sheetName: 'NPI Suspense',
title: 'Non-Performing Investments — Interest Held in Suspense',
subtitle,
headers: [
'Cert No',
'Script / Party',
'Type',
'Status',
'Provision Required',
'Interest in Suspense'
],
widths: [
14,
30,
16,
16,
16,
18
],
rows: this.suspenseRows.map((h) => [
h.certNo,
h.scriptName || h.party,
this.instrumentLabel(h.instrumentType),
this.provisionLabel(h),
this.provisionRequired(h),
this.suspense(h)
])
});
}
return sheets;
}
case 'fdCalc':
return [
{
sheetName: 'FD Interest Calc',
title: 'FD Interest Calculation & Post',
subtitle: `Period ${this.fdCalcFromDate || '—'} to ${this.fdCalcToDate || this.asOnDate} · Post to ${this.fdCalcGlCode} - ${this.glName(this.fdCalcGlCode)}`,
headers: [
'Cert No',
'Party',
'Face Value',
'Rate',
'Frequency',
'Interest'
],
widths: [
14,
30,
16,
10,
14,
16
],
rows: this.fdCalcTargets.map((h) => [
h.certNo,
h.party,
h.faceValue,
h.interestRate,
h.interestFreq,
this.fdInterest(h)
])
}
];
case 'bondCalc':
return [
{
sheetName: 'Bond Interest Calc',
title: 'Govt./Bond Interest Calculation & Post',
subtitle: `Period ${this.bondCalcFromDate || '—'} to ${this.bondCalcToDate || this.asOnDate} · Post to ${this.bondCalcGlCode} - ${this.glName(this.bondCalcGlCode)}`,
headers: [
'Cert No',
'Script',
'Type',
'Face Value',
'Rate',
'Frequency',
'Interest'
],
widths: [
14,
30,
16,
16,
10,
14,
16
],
rows: this.bondCalcTargets.map((h) => [
h.certNo,
h.scriptName,
this.instrumentLabel(h.instrumentType),
h.faceValue,
h.interestRate,
h.interestFreq,
this.bondInterest(h)
])
}
];
case 'receive': {
const sheets: ExportSheetSpec[] = [
{
sheetName: 'Receive Targets',
title: 'Interest Receive — Outstanding for Collection',
subtitle: `${subtitle} · Receive into ${this.receiveGlCode} - ${this.glName(this.receiveGlCode)}`,
headers: [
'Cert No',
'Script / Party',
'Type',
'Status',
'Outstanding'
],
widths: [
14,
30,
16,
16,
16
],
rows: this.receiveTargets.map((h) => [
h.certNo,
h.scriptName || h.party,
this.instrumentLabel(h.instrumentType),
this.provisionLabel(h),
this.outstandingFor(h)
])
}
];
if (this.receivedLog.length > 0) {
sheets.push({
sheetName: 'Received Log',
title: 'Interest Received Log',
subtitle,
headers: txnHeaders,
widths: txnWidths,
rows: this.receivedLog.map((t) => this.txnExportRow(t))
});
}
return sheets;
}
case 'bondIntRegister':
return one(
'Govt Bond Int Register',
'Government Bond Interest Register',
txnHeaders,
txnWidths,
this.bondIntRegister.map((t) => this.txnExportRow(t))
);
case 'fdIntRegister':
return one(
'FD Interest Register',
'FD Interest Register',
txnHeaders,
txnWidths,
this.fdIntRegister.map((t) => this.txnExportRow(t))
);
case 'fdRegister':
return one(
'FD Register',
'FD Register',
[
'Cert No',
'Party',
'Sub Type',
'Face Value',
'Rate',
'Frequency',
'Deposit Date',
'Maturity Date',
'Days Left',
'Accrued Interest',
'Maturity Value',
'Status'
],
[
14,
26,
16,
16,
10,
14,
14,
14,
12,
16,
16,
12
],
this.fdRegister.map((h) => [
h.certNo,
h.party,
h.subType,
h.faceValue,
h.interestRate,
h.interestFreq,
h.purchaseDate,
h.maturityDate,
this.daysLeft(h),
this.accrued(h),
this.maturity(h),
h.status
])
);
default:
return one('Interest', 'Investment Interest', [], [], []);
}
}
async exportExcel(): Promise<void> {
this.generating = true;
try {
await exportWorkbook(this.exportSheets(), `Investment-Interest-${this.activeTab}-${this.asOnDate}`);
this.toast('Exported.');
} catch (err) {
console.error('Investment Interest export failed', err);
this.toast('Could not generate the workbook — please retry.');
} finally {
this.generating = false;
}
}
private toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
if (this.toastTimer) {
clearTimeout(this.toastTimer);
}
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
}
}
@@ -0,0 +1,562 @@
<!--
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="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">TREASURY · INVESTMENT · MASTERS</div>
<h1 class="pg-title">Investment Masters</h1>
<p class="pg-sub">
Bank/Party, Script, SubType, Broker, Control Parameter, LIEN Reason, Mutual Fund Product, SGL Bank and Script
Market Detail masters.
</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
</div>
</header>
<div class="cctr-tabs">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'party'"
(click)="setTab('party')"
>
04 Bank / Party Master
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'script'"
(click)="setTab('script')"
>
05 Script Master
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'subtype'"
(click)="setTab('subtype')"
>
06 SubType Master
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'broker'"
(click)="setTab('broker')"
>
07 Broker Master
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'control'"
(click)="setTab('control')"
>
08 Control Parameter
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'dailyscript'"
(click)="setTab('dailyscript')"
>
09 Daily Script Master
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'lien'"
(click)="setTab('lien')"
>
10 LIEN Reason Master
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'mfproduct'"
(click)="setTab('mfproduct')"
>
11 Mutual Fund Product Master
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'sglbank'"
(click)="setTab('sglbank')"
>
12 SGL Bank Master
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'marketdetail'"
(click)="setTab('marketdetail')"
>
13 Script Market Detail
</button>
</div>
<!-- PARTY MASTER -->
@if (activeTab === 'party') {
<div class="pg-card">
<div class="pos-section-label">Add Bank / Party</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><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">
<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>Party Name</th>
<th>Type</th>
<th>PAN No</th>
</tr>
</thead>
<tbody>
@for (p of parties; track p.partyCode) {
<tr>
<td>
<span class="pg-chip">{{ p.partyCode }}</span>
</td>
<td>{{ p.partyName }}</td>
<td>{{ p.partyType }}</td>
<td>{{ p.panNo }}</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- SCRIPT MASTER -->
@if (activeTab === 'script') {
<div class="pg-card">
<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>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>ISIN</span><input type="text" [(ngModel)]="newScript.isin" /></label>
<label class="inv-form-field"
><span>Face Value / Unit</span><input type="number" [(ngModel)]="newScript.faceValuePerUnit"
/></label>
</div>
<div class="inv-form-actions">
<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>Script Name</th>
<th>Instrument</th>
<th>ISIN</th>
<th class="num">Face Value/Unit</th>
</tr>
</thead>
<tbody>
@for (s of scripts; track s.scriptCode) {
<tr>
<td>
<span class="pg-chip">{{ s.scriptCode }}</span>
</td>
<td>{{ s.scriptName }}</td>
<td>{{ s.instrumentType }}</td>
<td>{{ s.isin || '—' }}</td>
<td class="num">{{ s.faceValuePerUnit }}</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- SUBTYPE MASTER -->
@if (activeTab === 'subtype') {
<div class="pg-card">
<div class="pos-section-label">Add Sub Type</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Sub Type Name *</span><input type="text" [(ngModel)]="newSubType.subTypeName"
/></label>
<label class="inv-form-field">
<span>Instrument Type *</span>
<select [(ngModel)]="newSubType.instrumentType">
<option [ngValue]="undefined">-- Select --</option>
@for (t of instrumentTypes; track t) {
<option [value]="t">{{ t }}</option>
}
</select>
</label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="addSubType()">Save</button>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Code</th>
<th>Sub Type</th>
<th>Instrument</th>
</tr>
</thead>
<tbody>
@for (s of subTypes; track s.subTypeCode + s.subTypeName) {
<tr>
<td>
<span class="pg-chip">{{ s.subTypeCode }}</span>
</td>
<td>{{ s.subTypeName }}</td>
<td>{{ s.instrumentType }}</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- BROKER MASTER -->
@if (activeTab === 'broker') {
<div class="pg-card">
<div class="pos-section-label">Add Broker</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Broker Name *</span><input type="text" [(ngModel)]="newBroker.brokerName"
/></label>
<label class="inv-form-field"
><span>Contact No</span><input type="text" [(ngModel)]="newBroker.contactNo"
/></label>
<label class="inv-form-field"
><span>Empanelled On</span><mifosx-date-ddmmyyyy [(ngModel)]="newBroker.empanelledOn"></mifosx-date-ddmmyyyy
></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="addBroker()">Save</button>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Broker Code</th>
<th>Broker Name</th>
<th>Contact</th>
<th>Empanelled On</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (b of brokers; track b.brokerCode) {
<tr>
<td>
<span class="pg-chip">{{ b.brokerCode }}</span>
</td>
<td>{{ b.brokerName }}</td>
<td>{{ b.contactNo }}</td>
<td>{{ b.empanelledOn }}</td>
<td>
<span class="inv-badge inv-badge--active">{{ b.status }}</span>
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- CONTROL PARAMETER -->
@if (activeTab === 'control') {
<div class="pg-card">
<div class="pos-section-label">Add Control Parameter</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Parameter Name *</span><input type="text" [(ngModel)]="newControlParam.paramName"
/></label>
<label class="inv-form-field"
><span>Value *</span><input type="text" [(ngModel)]="newControlParam.paramValue"
/></label>
<label class="inv-form-field"
><span>Description</span><input type="text" [(ngModel)]="newControlParam.description"
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="addControlParam()">Save</button>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Parameter</th>
<th>Value</th>
<th>Description</th>
</tr>
</thead>
<tbody>
@for (c of controlParams; track c.paramName) {
<tr>
<td>
<span class="pg-chip">{{ c.paramName }}</span>
</td>
<td class="num">{{ c.paramValue }}</td>
<td>{{ c.description }}</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- DAILY SCRIPT MASTER -->
@if (activeTab === 'dailyscript') {
<div class="pg-card">
<div class="pos-section-label">Add Daily Script Rate</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Rate Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="newDailyRate.rateDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field">
<span>Script Code *</span>
<select [(ngModel)]="newDailyRate.scriptCode">
<option [ngValue]="undefined">-- Select --</option>
@for (s of scripts; track s.scriptCode) {
<option [value]="s.scriptCode">{{ s.scriptCode }} — {{ s.scriptName }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Open Rate</span><input type="number" [(ngModel)]="newDailyRate.openRate" step="0.01"
/></label>
<label class="inv-form-field"
><span>Close Rate *</span><input type="number" [(ngModel)]="newDailyRate.closeRate" step="0.01"
/></label>
<label class="inv-form-field"
><span>High Rate</span><input type="number" [(ngModel)]="newDailyRate.highRate" step="0.01"
/></label>
<label class="inv-form-field"
><span>Low Rate</span><input type="number" [(ngModel)]="newDailyRate.lowRate" step="0.01"
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="addDailyRate()">Save</button>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Rate Date</th>
<th>Script Code</th>
<th>Script Name</th>
<th class="num">Open</th>
<th class="num">High</th>
<th class="num">Low</th>
<th class="num">Close</th>
</tr>
</thead>
<tbody>
@for (r of dailyScriptRates; track r.rateDate + r.scriptCode) {
<tr>
<td>{{ r.rateDate }}</td>
<td>
<span class="pg-chip">{{ r.scriptCode }}</span>
</td>
<td>{{ r.scriptName }}</td>
<td class="num">{{ r.openRate | number: '1.2-2' }}</td>
<td class="num">{{ r.highRate | number: '1.2-2' }}</td>
<td class="num">{{ r.lowRate | number: '1.2-2' }}</td>
<td class="num">{{ r.closeRate | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- LIEN REASON MASTER -->
@if (activeTab === 'lien') {
<div class="pg-card">
<div class="pos-section-label">Add LIEN Reason</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Reason Description *</span><input type="text" [(ngModel)]="newLienReason.reasonDescription"
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="addLienReason()">Save</button>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Reason Code</th>
<th>Description</th>
</tr>
</thead>
<tbody>
@for (l of lienReasons; track l.reasonCode) {
<tr>
<td>
<span class="pg-chip">{{ l.reasonCode }}</span>
</td>
<td>{{ l.reasonDescription }}</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- MUTUAL FUND PRODUCT MASTER -->
@if (activeTab === 'mfproduct') {
<div class="pg-card">
<div class="pos-section-label">Add Mutual Fund Product</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Product Name *</span><input type="text" [(ngModel)]="newMfProduct.productName"
/></label>
<label class="inv-form-field"
><span>AMC Name *</span><input type="text" [(ngModel)]="newMfProduct.amcName"
/></label>
<label class="inv-form-field"
><span>Category</span><input type="text" [(ngModel)]="newMfProduct.category"
/></label>
<label class="inv-form-field">
<span>Risk Level</span>
<select [(ngModel)]="newMfProduct.riskLevel">
<option value="LOW">LOW</option>
<option value="MODERATE">MODERATE</option>
<option value="HIGH">HIGH</option>
</select>
</label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="addMfProduct()">Save</button>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Product Code</th>
<th>Product Name</th>
<th>AMC</th>
<th>Category</th>
<th>Risk</th>
</tr>
</thead>
<tbody>
@for (m of mfProducts; track m.productCode) {
<tr>
<td>
<span class="pg-chip">{{ m.productCode }}</span>
</td>
<td>{{ m.productName }}</td>
<td>{{ m.amcName }}</td>
<td>{{ m.category }}</td>
<td>{{ m.riskLevel }}</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- SGL BANK MASTER -->
@if (activeTab === 'sglbank') {
<div class="pg-card">
<div class="pos-section-label">Add SGL Bank</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Bank Name *</span><input type="text" [(ngModel)]="newSglBank.bankName"
/></label>
<label class="inv-form-field"><span>Branch</span><input type="text" [(ngModel)]="newSglBank.branch" /></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="addSglBank()">Save</button>
</div>
<table class="pg-table">
<thead>
<tr>
<th>SGL Code</th>
<th>Bank Name</th>
<th>Branch</th>
</tr>
</thead>
<tbody>
@for (s of sglBanks; track s.sglCode) {
<tr>
<td>
<span class="pg-chip">{{ s.sglCode }}</span>
</td>
<td>{{ s.bankName }}</td>
<td>{{ s.branch }}</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- SCRIPT MARKET DETAIL (also covers "09 Daily Script Master") -->
@if (activeTab === 'marketdetail') {
<div class="pg-card">
<div class="pos-section-label">Script Market Detail (Daily Script Rates)</div>
<table class="pg-table">
<thead>
<tr>
<th>Script Code</th>
<th class="num">Market Price</th>
<th>As On</th>
<th class="num">YTM (%)</th>
</tr>
</thead>
<tbody>
@for (m of marketDetails; track m.scriptCode) {
<tr>
<td>
<span class="pg-chip">{{ m.scriptCode }}</span>
</td>
<td class="num">₹{{ m.marketPrice | number: '1.2-2' }}</td>
<td>{{ m.asOnDate }}</td>
<td class="num">{{ m.ytm }}%</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
@@ -0,0 +1,517 @@
/**
* 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 } 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,
SGL_BANK_MASTER,
SCRIPT_MARKET_DETAIL,
DAILY_SCRIPT_RATES,
LIEN_REASON_MASTER,
ScriptMaster,
BrokerMaster,
PartyMaster,
SubTypeMaster,
ControlParameter,
MutualFundProductMaster,
SglBankMaster,
ScriptMarketDetail,
DailyScriptRate,
LienReason,
InstrumentType
} from '../investment-data';
import { exportWorkbook, ExportSheetSpec } from '../investment-export';
type MasterTab =
| 'party'
| 'script'
| 'subtype'
| 'broker'
| 'control'
| 'dailyscript'
| 'lien'
| 'mfproduct'
| 'sglbank'
| 'marketdetail';
/**
* Investment master-data screens (legacy "04-13" Entry submenu masters) — 9 simple dummy
* lists, each with an inline Add-row form so the demo stays interactive without a backend.
*/
@Component({
selector: 'mifosx-investment-masters',
templateUrl: './investment-masters.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../investment-shared.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
DateDdmmyyyyPickerComponent
]
})
export class InvestmentMastersComponent {
activeTab: MasterTab = 'party';
scripts: ScriptMaster[] = [...SCRIPT_MASTER];
brokers: BrokerMaster[] = [...BROKER_MASTER];
parties: PartyMaster[] = [...PARTY_MASTER];
subTypes: SubTypeMaster[] = [...SUBTYPE_MASTER];
controlParams: ControlParameter[] = [...CONTROL_PARAMETERS];
mfProducts: MutualFundProductMaster[] = [...MUTUAL_FUND_PRODUCTS];
sglBanks: SglBankMaster[] = [...SGL_BANK_MASTER];
marketDetails: ScriptMarketDetail[] = [...SCRIPT_MARKET_DETAIL];
dailyScriptRates: DailyScriptRate[] = [...DAILY_SCRIPT_RATES];
lienReasons: LienReason[] = [...LIEN_REASON_MASTER];
readonly instrumentTypes: InstrumentType[] = [
'FD',
'GOVT_SECURITY',
'NON_SLR',
'BOND',
'MUTUAL_FUND',
'SHARE'
];
private readonly masterLabels: Record<MasterTab, string> = {
party: 'Bank / Party Master',
script: 'Script Master',
subtype: 'SubType Master',
broker: 'Broker Master',
control: 'Control Parameter',
dailyscript: 'Daily Script Master',
lien: 'LIEN Reason Master',
mfproduct: 'Mutual Fund Product Master',
sglbank: 'SGL Bank Master',
marketdetail: 'Script Market Detail'
};
generating = false;
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
newParty: Partial<PartyMaster> = {};
newScript: Partial<ScriptMaster> = {};
newSubType: Partial<SubTypeMaster> = {};
newBroker: Partial<BrokerMaster> = {};
newControlParam: Partial<ControlParameter> = {};
newLienReason: Partial<LienReason> = {};
newMfProduct: Partial<MutualFundProductMaster> = {};
newSglBank: Partial<SglBankMaster> = {};
newDailyRate: Partial<DailyScriptRate> = {};
setTab(tab: MasterTab): void {
this.activeTab = tab;
}
addParty(): void {
if (!this.newParty.partyName || !this.newParty.partyType) {
this.toast('Enter Party Name and Type.');
return;
}
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.');
}
addScript(): void {
if (!this.newScript.scriptName || !this.newScript.instrumentType) {
this.toast('Enter Script Name and Instrument Type.');
return;
}
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 {
if (!this.newSubType.subTypeName || !this.newSubType.instrumentType) {
this.toast('Enter Sub Type Name and Instrument Type.');
return;
}
this.subTypes = [
{ subTypeCode: String(this.subTypes.length + 1), ...this.newSubType } as SubTypeMaster,
...this.subTypes
];
this.newSubType = {};
this.toast('Sub Type master added.');
}
addBroker(): void {
if (!this.newBroker.brokerName) {
this.toast('Enter Broker Name.');
return;
}
this.brokers = [
{
brokerCode: `BRK-${String(this.brokers.length + 1).padStart(3, '0')}`,
contactNo: '',
empanelledOn: '',
status: 'ACTIVE',
...this.newBroker
} as BrokerMaster,
...this.brokers
];
this.newBroker = {};
this.toast('Broker master added.');
}
addControlParam(): void {
if (!this.newControlParam.paramName || !this.newControlParam.paramValue) {
this.toast('Enter Parameter Name and Value.');
return;
}
this.controlParams = [
{ description: '', ...this.newControlParam } as ControlParameter,
...this.controlParams
];
this.newControlParam = {};
this.toast('Control parameter added.');
}
addLienReason(): void {
if (!this.newLienReason.reasonDescription) {
this.toast('Enter Reason Description.');
return;
}
this.lienReasons = [
{ reasonCode: `LR-${String(this.lienReasons.length + 1).padStart(2, '0')}`, ...this.newLienReason } as LienReason,
...this.lienReasons
];
this.newLienReason = {};
this.toast('LIEN reason added.');
}
addMfProduct(): void {
if (!this.newMfProduct.productName || !this.newMfProduct.amcName) {
this.toast('Enter Product Name and AMC Name.');
return;
}
this.mfProducts = [
{
productCode: `MFP-${String(this.mfProducts.length + 1).padStart(2, '0')}`,
category: '',
riskLevel: 'MODERATE',
...this.newMfProduct
} as MutualFundProductMaster,
...this.mfProducts
];
this.newMfProduct = {};
this.toast('Mutual Fund product added.');
}
addSglBank(): void {
if (!this.newSglBank.bankName) {
this.toast('Enter Bank Name.');
return;
}
this.sglBanks = [
{
sglCode: `SGL-${String(this.sglBanks.length + 1).padStart(2, '0')}`,
branch: '',
...this.newSglBank
} as SglBankMaster,
...this.sglBanks
];
this.newSglBank = {};
this.toast('SGL Bank master added.');
}
addDailyRate(): void {
if (!this.newDailyRate.scriptCode || !this.newDailyRate.rateDate || this.newDailyRate.closeRate == null) {
this.toast('Enter Rate Date, Script Code and Close Rate.');
return;
}
const script = this.scripts.find((s) => s.scriptCode === this.newDailyRate.scriptCode);
this.dailyScriptRates = [
{
scriptName: script?.scriptName || '',
openRate: this.newDailyRate.closeRate,
highRate: this.newDailyRate.closeRate,
lowRate: this.newDailyRate.closeRate,
...this.newDailyRate
} as DailyScriptRate,
...this.dailyScriptRates
];
this.newDailyRate = {};
this.toast('Daily script rate added.');
}
// -------------------- Export to Excel --------------------
private exportSheets(): ExportSheetSpec[] {
const title = this.masterLabels[this.activeTab];
const one = (headers: string[], widths: number[], rows: (string | number | null)[][]): ExportSheetSpec[] => [
{ sheetName: title, title, headers, widths, rows }];
switch (this.activeTab) {
case 'party':
return one(
[
'Party Code',
'Party Name',
'Type',
'PAN No'
],
[
12,
30,
16,
14
],
this.parties.map((p) => [
p.partyCode,
p.partyName,
p.partyType,
p.panNo
])
);
case 'script':
return one(
[
'Script Code',
'Script Name',
'Instrument',
'ISIN',
'Face Value/Unit'
],
[
16,
30,
16,
16,
16
],
this.scripts.map((s) => [
s.scriptCode,
s.scriptName,
s.instrumentType,
s.isin,
s.faceValuePerUnit
])
);
case 'subtype':
return one(
[
'Code',
'Sub Type',
'Instrument'
],
[
10,
24,
16
],
this.subTypes.map((s) => [
s.subTypeCode,
s.subTypeName,
s.instrumentType
])
);
case 'broker':
return one(
[
'Broker Code',
'Broker Name',
'Contact',
'Empanelled On',
'Status'
],
[
14,
30,
16,
16,
12
],
this.brokers.map((b) => [
b.brokerCode,
b.brokerName,
b.contactNo,
b.empanelledOn,
b.status
])
);
case 'control':
return one(
[
'Parameter',
'Value',
'Description'
],
[
26,
14,
40
],
this.controlParams.map((c) => [
c.paramName,
c.paramValue,
c.description
])
);
case 'dailyscript':
return one(
[
'Rate Date',
'Script Code',
'Script Name',
'Open',
'High',
'Low',
'Close'
],
[
14,
16,
26,
12,
12,
12,
12
],
this.dailyScriptRates.map((r) => [
r.rateDate,
r.scriptCode,
r.scriptName,
r.openRate,
r.highRate,
r.lowRate,
r.closeRate
])
);
case 'lien':
return one(
[
'Reason Code',
'Description'
],
[
14,
40
],
this.lienReasons.map((l) => [
l.reasonCode,
l.reasonDescription
])
);
case 'mfproduct':
return one(
[
'Product Code',
'Product Name',
'AMC',
'Category',
'Risk'
],
[
14,
30,
24,
20,
12
],
this.mfProducts.map((m) => [
m.productCode,
m.productName,
m.amcName,
m.category,
m.riskLevel
])
);
case 'sglbank':
return one(
[
'SGL Code',
'Bank Name',
'Branch'
],
[
14,
30,
24
],
this.sglBanks.map((s) => [
s.sglCode,
s.bankName,
s.branch
])
);
case 'marketdetail':
return one(
[
'Script Code',
'Market Price',
'As On',
'YTM %'
],
[
16,
16,
14,
12
],
this.marketDetails.map((m) => [
m.scriptCode,
m.marketPrice,
m.asOnDate,
m.ytm
])
);
default:
return one([], [], []);
}
}
async exportExcel(): Promise<void> {
this.generating = true;
try {
await exportWorkbook(this.exportSheets(), `Investment-${this.masterLabels[this.activeTab]}`);
this.toast(`${this.masterLabels[this.activeTab]} exported.`);
} catch (err) {
console.error('Investment Masters export failed', err);
this.toast('Could not generate the workbook — please retry.');
} finally {
this.generating = false;
}
}
private toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
if (this.toastTimer) {
clearTimeout(this.toastTimer);
}
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
}
}
@@ -0,0 +1,701 @@
<!--
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="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">TREASURY · INVESTMENT · REPORTS</div>
<h1 class="pg-title">Investment Reports</h1>
<p class="pg-sub">
Balance reports, ledgers, registers, maturity lists and the consolidated portfolio — all derived live from the
same holdings/transactions used by the Entry screens.
</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
</div>
</header>
<div class="cctr-tabs">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeGroup === 'balance'"
(click)="setGroup('balance', 'govtSecBalance')"
>
Balance Reports
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeGroup === 'ledger'"
(click)="setGroup('ledger', 'fdLedger')"
>
Ledgers &amp; Registers
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeGroup === 'maturity'"
(click)="setGroup('maturity', 'maturedFd')"
>
Maturity &amp; Lists
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeGroup === 'portfolio'"
(click)="setGroup('portfolio', 'portfolio')"
>
Portfolio
</button>
</div>
<!-- ===================== BALANCE REPORTS ===================== -->
@if (activeGroup === 'balance') {
<div class="cctr-tabs">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'govtSecBalance'"
(click)="setReport('govtSecBalance')"
>
Govt Security Balance
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'shareBalance'"
(click)="setReport('shareBalance')"
>
Share Balance
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'fdrBalance'"
(click)="setReport('fdrBalance')"
>
FDR Balance
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'bankInvList'"
(click)="setReport('bankInvList')"
>
Bank Investment List
</button>
</div>
@if (activeReport === 'govtSecBalance' || activeReport === 'shareBalance' || activeReport === 'fdrBalance') {
<div class="pg-card">
<div class="pos-section-label">
{{
activeReport === 'govtSecBalance'
? 'Govt Security Balance Report'
: activeReport === 'shareBalance'
? 'Share Balance Report'
: 'FDR Balance Report'
}}
</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script / Party</th>
<th class="num">Units</th>
<th class="num">Face Value</th>
<th class="num">Market/Purchase Rate</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (
h of activeReport === 'govtSecBalance'
? govtSecHoldings
: activeReport === 'shareBalance'
? shareHoldings
: fdHoldings;
track h.certNo
) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName }} — {{ h.party }}</td>
<td class="num">{{ h.units | number }}</td>
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
<td class="num">{{ marketPrice(h.scriptCode) || h.purchaseRate }}</td>
<td>
<span [class]="statusClass(h)">{{ h.status }}</span>
</td>
</tr>
}
</tbody>
</table>
</div>
}
@if (activeReport === 'bankInvList') {
<div class="pg-card">
<div class="pos-section-label">Bank Investment List</div>
<table class="pg-table">
<thead>
<tr>
<th>Party / Bank</th>
<th class="num">No. of Holdings</th>
<th class="num">Total Face Value</th>
</tr>
</thead>
<tbody>
@for (r of bankInvestmentList; track r.party) {
<tr>
<td>{{ r.party }}</td>
<td class="num">{{ r.count }}</td>
<td class="num">₹{{ r.totalFaceValue | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
}
<!-- ===================== LEDGERS & REGISTERS ===================== -->
@if (activeGroup === 'ledger') {
<div class="cctr-tabs">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'fdLedger'"
(click)="setReport('fdLedger')"
>
FD Ledger
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'bondLedger'"
(click)="setReport('bondLedger')"
>
Govt/Bond Ledger
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'shareLedger'"
(click)="setReport('shareLedger')"
>
Share Ledger
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'intDueRegister'"
(click)="setReport('intDueRegister')"
>
Interest Due Register
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'slrRegister'"
(click)="setReport('slrRegister')"
>
SLR Bond Register
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'pendingReceived'"
(click)="setReport('pendingReceived')"
>
Pending/Received Interest
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'amortizeLedger'"
(click)="setReport('amortizeLedger')"
>
Amortize Ledger
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'dealSlip'"
(click)="setReport('dealSlip')"
>
Deal Slip
</button>
</div>
@if (
activeReport === 'fdLedger' ||
activeReport === 'bondLedger' ||
activeReport === 'shareLedger' ||
activeReport === 'amortizeLedger' ||
activeReport === 'dealSlip'
) {
<div class="pg-card">
<div class="pos-section-label">
{{
activeReport === 'fdLedger'
? 'FD Ledger'
: activeReport === 'bondLedger'
? 'Govt/Bond Ledger'
: activeReport === 'shareLedger'
? 'Share Ledger'
: activeReport === 'amortizeLedger'
? 'Amortize Ledger'
: 'Deal Slip Register'
}}
</div>
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>Voucher No</th>
<th>Cert No</th>
<th>Type</th>
<th>Particulars</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (
t of activeReport === 'fdLedger'
? fdTxns
: activeReport === 'bondLedger'
? bondTxns
: activeReport === 'shareLedger'
? shareTxns
: activeReport === 'amortizeLedger'
? amortizeTxns
: dealSlips;
track t.voucherNo
) {
<tr>
<td>{{ t.txnDate }}</td>
<td>
<span class="pg-chip">{{ t.voucherNo }}</span>
</td>
<td>{{ t.certNo }}</td>
<td>{{ t.txnType }}</td>
<td>{{ t.particulars }}</td>
<td class="num">₹{{ t.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="pg-empty">No entries.</td>
</tr>
}
</tbody>
</table>
</div>
}
@if (activeReport === 'intDueRegister') {
<div class="pg-card">
<div class="pos-section-label">Interest Due Register (Non-Cumulative Holdings)</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script / Party</th>
<th>Frequency</th>
<th class="num">Rate</th>
<th class="num col-in">Due Amount (as on {{ asOnDate }})</th>
</tr>
</thead>
<tbody>
@for (h of interestDueRegister; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName }} — {{ h.party }}</td>
<td>{{ h.interestFreq }}</td>
<td class="num">{{ h.interestRate }}%</td>
<td class="num col-in">₹{{ h.dueAmount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
@if (activeReport === 'slrRegister') {
<div class="pg-card">
<div class="pos-section-label">SLR Bond Register</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script</th>
<th class="num">Face Value</th>
<th class="num">Rate</th>
<th>Maturity Date</th>
</tr>
</thead>
<tbody>
@for (h of slrBondRegister; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName }}</td>
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
<td class="num">{{ h.interestRate }}%</td>
<td>{{ h.maturityDate }}</td>
</tr>
}
</tbody>
</table>
</div>
}
@if (activeReport === 'pendingReceived') {
<div class="pg-card">
<div class="pos-section-label">Received Interest / Dividend</div>
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>Cert No</th>
<th>Particulars</th>
<th class="num col-in">Amount</th>
</tr>
</thead>
<tbody>
@for (t of receivedInterestTxns; track t.voucherNo) {
<tr>
<td>{{ t.txnDate }}</td>
<td>{{ t.certNo }}</td>
<td>{{ t.particulars }}</td>
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
<div class="pg-card">
<div class="pos-section-label">Pending Interest (No Interest Posted Yet)</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script / Party</th>
<th class="num col-out">Accrued (Pending)</th>
</tr>
</thead>
<tbody>
@for (h of pendingInterestHoldings; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName }} — {{ h.party }}</td>
<td class="num col-out">₹{{ accrued(h) | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
}
<!-- ===================== MATURITY & LISTS ===================== -->
@if (activeGroup === 'maturity') {
<div class="cctr-tabs">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'maturedFd'"
(click)="setReport('maturedFd')"
>
List of Matured FD
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'maturingList'"
(click)="setReport('maturingList')"
>
Govt/FD Mature List
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'maturityPattern'"
(click)="setReport('maturityPattern')"
>
Maturity Pattern Register
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'govtSecList'"
(click)="setReport('govtSecList')"
>
Govt Security List
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'fdSecList'"
(click)="setReport('fdSecList')"
>
FD Security List
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'shareSecList'"
(click)="setReport('shareSecList')"
>
Share Security List
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'mfList'"
(click)="setReport('mfList')"
>
Mutual Fund List
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'shiftingReport'"
(click)="setReport('shiftingReport')"
>
Shifting Report
</button>
</div>
@if (activeReport === 'maturedFd') {
<div class="pg-card">
<div class="pos-section-label">List of Matured FD</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Party</th>
<th class="num">Face Value</th>
<th>Matured On</th>
</tr>
</thead>
<tbody>
@for (h of maturedFdList; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.party }}</td>
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
<td>{{ h.maturityDate }}</td>
</tr>
} @empty {
<tr>
<td colspan="4" class="pg-empty">No matured FDs.</td>
</tr>
}
</tbody>
</table>
</div>
}
@if (activeReport === 'maturingList') {
<div class="pg-card">
<div class="pos-section-label">Govt Security / FD Maturity List (sorted by days-to-maturity)</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Instrument</th>
<th>Script / Party</th>
<th>Maturity Date</th>
<th class="num">Days Left</th>
<th class="num col-net">Maturity Value</th>
</tr>
</thead>
<tbody>
@for (h of maturingList; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ instrumentLabel(h.instrumentType) }}</td>
<td>{{ h.scriptName }} — {{ h.party }}</td>
<td>{{ h.maturityDate }}</td>
<td class="num">{{ daysLeft(h) }}</td>
<td class="num col-net">₹{{ maturity(h) | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
@if (activeReport === 'maturityPattern') {
<div class="pg-card">
<div class="pos-section-label">Maturity Pattern Register</div>
<table class="pg-table">
<thead>
<tr>
<th>Bucket</th>
<th class="num">Holdings</th>
<th class="num">Face Value</th>
</tr>
</thead>
<tbody>
@for (b of maturityBuckets; track b.label) {
<tr>
<td>{{ b.label }}</td>
<td class="num">{{ b.count }}</td>
<td class="num">₹{{ b.faceValue | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
@if (
activeReport === 'govtSecList' ||
activeReport === 'fdSecList' ||
activeReport === 'shareSecList' ||
activeReport === 'mfList'
) {
<div class="pg-card">
<div class="pos-section-label">
{{
activeReport === 'govtSecList'
? 'Govt Security List'
: activeReport === 'fdSecList'
? 'FD Security List'
: activeReport === 'shareSecList'
? 'Share Security List'
: 'Mutual Fund List'
}}
</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script</th>
<th>Party</th>
<th class="num">Face Value</th>
<th class="num">Rate</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (
h of activeReport === 'govtSecList'
? govtSecList
: activeReport === 'fdSecList'
? fdSecList
: activeReport === 'shareSecList'
? shareSecList
: mutualFundList;
track h.certNo
) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName }}</td>
<td>{{ h.party }}</td>
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
<td class="num">{{ h.interestRate }}%</td>
<td>
<span [class]="statusClass(h)">{{ h.status }}</span>
</td>
</tr>
}
</tbody>
</table>
</div>
}
@if (activeReport === 'shiftingReport') {
<div class="pg-card">
<div class="pos-section-label">
Shifting Report — SLR Securities with Market Deviation ≥ 1% (AFS/HTM review candidates)
</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script</th>
<th class="num">Purchase Rate</th>
<th class="num">Market Price</th>
<th class="num">Deviation %</th>
</tr>
</thead>
<tbody>
@for (h of shiftingCandidates; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName }}</td>
<td class="num">{{ h.purchaseRate }}</td>
<td class="num">{{ h.marketPrice }}</td>
<td class="num" [class.col-in]="h.deviationPct > 0" [class.col-out]="h.deviationPct < 0">
{{ h.deviationPct }}%
</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="pg-empty">No securities crossing the 1% deviation threshold today.</td>
</tr>
}
</tbody>
</table>
</div>
}
}
<!-- ===================== PORTFOLIO ===================== -->
@if (activeGroup === 'portfolio') {
<div class="pg-card">
<div class="pos-section-label">Investment Portfolio — Consolidated Summary</div>
<table class="pg-table">
<thead>
<tr>
<th>Instrument Type</th>
<th class="num">Holdings</th>
<th class="num">Face Value</th>
<th class="num col-in">Accrued Interest</th>
<th class="num col-net">Maturity Value</th>
</tr>
</thead>
<tbody>
@for (r of portfolioSummary; track r.label) {
<tr>
<td>{{ r.label }}</td>
<td class="num">{{ r.count }}</td>
<td class="num">₹{{ r.faceValue | number: '1.2-2' }}</td>
<td class="num col-in">₹{{ r.accrued | number: '1.2-2' }}</td>
<td class="num col-net">₹{{ r.maturityValue | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td>Total</td>
<td class="num"></td>
<td class="num">₹{{ portfolioTotalFaceValue | number: '1.2-2' }}</td>
<td class="num">₹{{ portfolioTotalAccrued | number: '1.2-2' }}</td>
<td class="num">₹{{ portfolioTotalMaturity | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
</div>
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
@@ -0,0 +1,613 @@
/**
* 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 } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
ALL_HOLDINGS,
ALL_TXNS,
FD_HOLDINGS,
GOVT_SECURITY_HOLDINGS,
NON_SLR_HOLDINGS,
BOND_HOLDINGS,
MUTUAL_FUND_HOLDINGS,
SHARE_HOLDINGS,
SCRIPT_MARKET_DETAIL,
InstrumentType,
InvestmentHolding,
InvestmentTxn,
INSTRUMENT_LABELS,
accruedInterest,
maturityAmount,
daysToMaturity,
todayDDMMYYYY
} from '../investment-data';
import { exportWorkbook, ExportSheetSpec } from '../investment-export';
type ReportGroup = 'balance' | 'ledger' | 'maturity' | 'portfolio';
/**
* ~20 legacy Investment reports, grouped into 4 tabs. Every figure here is derived from the
* same ALL_HOLDINGS/ALL_TXNS arrays used by the Entry screens and Dashboard — so a report can
* never drift out of sync with what a Purchase/Sell/Interest entry actually recorded.
*/
@Component({
selector: 'mifosx-investment-reports',
templateUrl: './investment-reports.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../investment-shared.scss'
],
standalone: true,
imports: [CommonModule]
})
export class InvestmentReportsComponent {
activeGroup: ReportGroup = 'balance';
activeReport = 'govtSecBalance';
asOnDate = todayDDMMYYYY();
generating = false;
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
setGroup(group: ReportGroup, defaultReport: string): void {
this.activeGroup = group;
this.activeReport = defaultReport;
}
setReport(report: string): void {
this.activeReport = report;
}
instrumentLabel(type: InstrumentType): string {
return INSTRUMENT_LABELS[type];
}
accrued(h: InvestmentHolding): number {
return accruedInterest(h, this.asOnDate);
}
maturity(h: InvestmentHolding): number {
return maturityAmount(h);
}
daysLeft(h: InvestmentHolding): number {
return daysToMaturity(h, this.asOnDate);
}
statusClass(h: InvestmentHolding): string {
return `inv-badge inv-badge--${h.status.toLowerCase()}`;
}
marketPrice(scriptCode: string): number | null {
return SCRIPT_MARKET_DETAIL.find((m) => m.scriptCode === scriptCode)?.marketPrice ?? null;
}
// -------------------- Balance Reports --------------------
get govtSecHoldings(): InvestmentHolding[] {
return GOVT_SECURITY_HOLDINGS;
}
get shareHoldings(): InvestmentHolding[] {
return SHARE_HOLDINGS;
}
get fdHoldings(): InvestmentHolding[] {
return FD_HOLDINGS;
}
get bankInvestmentList(): Array<{ party: string; count: number; totalFaceValue: number }> {
const map = new Map<string, { party: string; count: number; totalFaceValue: number }>();
for (const h of ALL_HOLDINGS) {
const row = map.get(h.party) || { party: h.party, count: 0, totalFaceValue: 0 };
row.count += 1;
row.totalFaceValue += h.faceValue;
map.set(h.party, row);
}
return [...map.values()].sort((a, b) => b.totalFaceValue - a.totalFaceValue);
}
// -------------------- Ledgers & Registers --------------------
get fdTxns(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => t.instrumentType === 'FD');
}
get bondTxns(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => ([
'GOVT_SECURITY',
'NON_SLR',
'BOND'
] as InstrumentType[]).includes(t.instrumentType));
}
get shareTxns(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => t.instrumentType === 'SHARE');
}
get amortizeTxns(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => t.txnType === 'AMORTIZE');
}
get dealSlips(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => t.txnType === 'PURCHASE');
}
get slrBondRegister(): InvestmentHolding[] {
return GOVT_SECURITY_HOLDINGS.filter((h) => h.subType === 'SLR');
}
get interestDueRegister(): Array<InvestmentHolding & { dueAmount: number }> {
return ALL_HOLDINGS.filter((h) => h.status === 'ACTIVE' && h.interestFreq !== 'CUMULATIVE').map((h) => ({
...h,
dueAmount: this.accrued(h)
}));
}
get receivedInterestTxns(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => t.txnType === 'INTEREST' || t.txnType === 'DIVIDEND');
}
get pendingInterestHoldings(): InvestmentHolding[] {
const receivedCerts = new Set(this.receivedInterestTxns.map((t) => t.certNo));
return ALL_HOLDINGS.filter(
(h) => h.status === 'ACTIVE' && h.interestFreq !== 'CUMULATIVE' && !receivedCerts.has(h.certNo)
);
}
// -------------------- Maturity & Lists --------------------
get maturedFdList(): InvestmentHolding[] {
return FD_HOLDINGS.filter((h) => h.status === 'MATURED');
}
get maturingList(): InvestmentHolding[] {
return [
...FD_HOLDINGS,
...GOVT_SECURITY_HOLDINGS
]
.filter((h) => h.status === 'ACTIVE')
.sort((a, b) => this.daysLeft(a) - this.daysLeft(b));
}
get maturityBuckets(): Array<{ label: string; count: number; faceValue: number }> {
const buckets = [
{ label: '0-1 Year', min: -99999, max: 365, count: 0, faceValue: 0 },
{ label: '1-3 Years', min: 366, max: 1095, count: 0, faceValue: 0 },
{ label: '3-5 Years', min: 1096, max: 1825, count: 0, faceValue: 0 },
{ label: 'Above 5 Years', min: 1826, max: 999999, count: 0, faceValue: 0 }
];
for (const h of ALL_HOLDINGS.filter((x) => x.status === 'ACTIVE')) {
const days = this.daysLeft(h);
const bucket = buckets.find((b) => days >= b.min && days <= b.max);
if (bucket) {
bucket.count += 1;
bucket.faceValue += h.faceValue;
}
}
return buckets;
}
get govtSecList(): InvestmentHolding[] {
return GOVT_SECURITY_HOLDINGS;
}
get fdSecList(): InvestmentHolding[] {
return FD_HOLDINGS;
}
get shareSecList(): InvestmentHolding[] {
return SHARE_HOLDINGS;
}
get mutualFundList(): InvestmentHolding[] {
return MUTUAL_FUND_HOLDINGS;
}
get nonSlrList(): InvestmentHolding[] {
return NON_SLR_HOLDINGS;
}
get bondList(): InvestmentHolding[] {
return BOND_HOLDINGS;
}
/** Shifting Report — SLR securities whose market value has moved away from book value by
* more than 1%, a proxy for "category shift" candidates (AFS/HTM reclassification review). */
get shiftingCandidates(): Array<InvestmentHolding & { marketPrice: number; deviationPct: number }> {
return GOVT_SECURITY_HOLDINGS.map((h) => {
const mp = this.marketPrice(h.scriptCode);
if (mp === null) {
return null;
}
const deviationPct = +(((mp - h.purchaseRate) / h.purchaseRate) * 100).toFixed(2);
return { ...h, marketPrice: mp, deviationPct };
})
.filter(
(x): x is InvestmentHolding & { marketPrice: number; deviationPct: number } =>
x !== null && Math.abs(x.deviationPct) >= 1
)
.sort((a, b) => Math.abs(b.deviationPct) - Math.abs(a.deviationPct));
}
// -------------------- Portfolio --------------------
private readonly portfolioTypes: InstrumentType[] = [
'FD',
'GOVT_SECURITY',
'NON_SLR',
'BOND',
'MUTUAL_FUND',
'SHARE'
];
get portfolioSummary(): Array<{
label: string;
count: number;
faceValue: number;
accrued: number;
maturityValue: number;
}> {
return this.portfolioTypes.map((type) => {
const holdings = ALL_HOLDINGS.filter((h) => h.instrumentType === type);
return {
label: INSTRUMENT_LABELS[type],
count: holdings.length,
faceValue: holdings.reduce((s, h) => s + h.faceValue, 0),
accrued: holdings.reduce((s, h) => s + this.accrued(h), 0),
maturityValue: holdings.reduce((s, h) => s + this.maturity(h), 0)
};
});
}
get portfolioTotalFaceValue(): number {
return this.portfolioSummary.reduce((s, r) => s + r.faceValue, 0);
}
get portfolioTotalAccrued(): number {
return this.portfolioSummary.reduce((s, r) => s + r.accrued, 0);
}
get portfolioTotalMaturity(): number {
return this.portfolioSummary.reduce((s, r) => s + r.maturityValue, 0);
}
// -------------------- Export to Excel --------------------
private readonly holdingsExportHeaders = [
'Cert No',
'Script / Party',
'Sub Type',
'Units',
'Face Value',
'Purchase Rate',
'Interest Rate',
'Purchase Date',
'Maturity Date',
'Accrued Interest',
'Maturity Value',
'Status'
];
private readonly holdingsExportWidths = [
14,
30,
16,
12,
16,
14,
12,
14,
14,
16,
16,
12
];
private holdingsExportRow(h: InvestmentHolding): (string | number | null)[] {
return [
h.certNo,
h.scriptName || h.party,
h.subType,
h.units,
h.faceValue,
h.purchaseRate,
h.interestRate,
h.purchaseDate,
h.maturityDate,
this.accrued(h),
this.maturity(h),
h.status
];
}
private readonly txnExportHeaders = [
'Date',
'Voucher No',
'Cert No',
'Type',
'Particulars',
'Amount'
];
private readonly txnExportWidths = [
14,
22,
14,
14,
42,
16
];
private txnExportRow(t: InvestmentTxn): (string | number | null)[] {
return [
t.txnDate,
t.voucherNo,
t.certNo,
t.txnType,
t.particulars,
t.amount
];
}
private exportSheets(): ExportSheetSpec[] {
const subtitle = `As on ${this.asOnDate}`;
const one = (
sheetName: string,
title: string,
headers: string[],
widths: number[],
rows: (string | number | null)[][]
): ExportSheetSpec[] => [
{ sheetName, title, subtitle, headers, widths, rows }];
switch (this.activeReport) {
case 'govtSecBalance':
return one(
'Govt Sec Balance',
'Govt Security Balance Report',
this.holdingsExportHeaders,
this.holdingsExportWidths,
this.govtSecHoldings.map((h) => this.holdingsExportRow(h))
);
case 'shareBalance':
return one(
'Share Balance',
'Share Balance Report',
this.holdingsExportHeaders,
this.holdingsExportWidths,
this.shareHoldings.map((h) => this.holdingsExportRow(h))
);
case 'fdrBalance':
return one(
'FDR Balance',
'FDR Balance Report',
this.holdingsExportHeaders,
this.holdingsExportWidths,
this.fdHoldings.map((h) => this.holdingsExportRow(h))
);
case 'bankInvList':
return one(
'Bank Investment List',
'Bank Investment List',
[
'Party',
'Holdings',
'Total Face Value'
],
[
30,
14,
18
],
this.bankInvestmentList.map((r) => [
r.party,
r.count,
r.totalFaceValue
])
);
case 'fdLedger':
return one(
'FD Ledger',
'FD Ledger',
this.txnExportHeaders,
this.txnExportWidths,
this.fdTxns.map((t) => this.txnExportRow(t))
);
case 'bondLedger':
return one(
'Govt-Bond Ledger',
'Govt/Bond Ledger',
this.txnExportHeaders,
this.txnExportWidths,
this.bondTxns.map((t) => this.txnExportRow(t))
);
case 'shareLedger':
return one(
'Share Ledger',
'Share Ledger',
this.txnExportHeaders,
this.txnExportWidths,
this.shareTxns.map((t) => this.txnExportRow(t))
);
case 'amortizeLedger':
return one(
'Amortize Ledger',
'Amortize Ledger',
this.txnExportHeaders,
this.txnExportWidths,
this.amortizeTxns.map((t) => this.txnExportRow(t))
);
case 'dealSlip':
return one(
'Deal Slip',
'Deal Slip Register',
this.txnExportHeaders,
this.txnExportWidths,
this.dealSlips.map((t) => this.txnExportRow(t))
);
case 'intDueRegister':
return one(
'Interest Due Register',
'Interest Due Register (Non-Cumulative Holdings)',
[
...this.holdingsExportHeaders,
'Due Amount'
],
[
...this.holdingsExportWidths,
16
],
this.interestDueRegister.map((h) => [
...this.holdingsExportRow(h),
h.dueAmount
])
);
case 'slrRegister':
return one(
'SLR Bond Register',
'SLR Bond Register',
this.holdingsExportHeaders,
this.holdingsExportWidths,
this.slrBondRegister.map((h) => this.holdingsExportRow(h))
);
case 'pendingReceived':
return [
{
sheetName: 'Received Interest',
title: 'Received Interest / Dividend',
subtitle,
headers: this.txnExportHeaders,
widths: this.txnExportWidths,
rows: this.receivedInterestTxns.map((t) => this.txnExportRow(t))
},
{
sheetName: 'Pending Interest',
title: 'Pending Interest (Not Yet Posted)',
subtitle,
headers: this.holdingsExportHeaders,
widths: this.holdingsExportWidths,
rows: this.pendingInterestHoldings.map((h) => this.holdingsExportRow(h))
}
];
case 'maturedFd':
return one(
'Matured FD',
'List of Matured FD',
this.holdingsExportHeaders,
this.holdingsExportWidths,
this.maturedFdList.map((h) => this.holdingsExportRow(h))
);
case 'maturingList':
return one(
'Maturity List',
'Govt Security / FD Maturity List',
this.holdingsExportHeaders,
this.holdingsExportWidths,
this.maturingList.map((h) => this.holdingsExportRow(h))
);
case 'maturityPattern':
return one(
'Maturity Pattern',
'Maturity Pattern Register',
[
'Bucket',
'Holdings',
'Face Value'
],
[
22,
14,
18
],
this.maturityBuckets.map((b) => [
b.label,
b.count,
b.faceValue
])
);
case 'govtSecList':
return one(
'Govt Security List',
'Govt Security List',
this.holdingsExportHeaders,
this.holdingsExportWidths,
this.govtSecList.map((h) => this.holdingsExportRow(h))
);
case 'fdSecList':
return one(
'FD Security List',
'FD Security List',
this.holdingsExportHeaders,
this.holdingsExportWidths,
this.fdSecList.map((h) => this.holdingsExportRow(h))
);
case 'shareSecList':
return one(
'Share Security List',
'Share Security List',
this.holdingsExportHeaders,
this.holdingsExportWidths,
this.shareSecList.map((h) => this.holdingsExportRow(h))
);
case 'mfList':
return one(
'Mutual Fund List',
'Mutual Fund List',
this.holdingsExportHeaders,
this.holdingsExportWidths,
this.mutualFundList.map((h) => this.holdingsExportRow(h))
);
case 'shiftingReport':
return one(
'Shifting Report',
'Shifting Report — SLR Market Deviation ≥ 1%',
[
'Cert No',
'Script',
'Purchase Rate',
'Market Price',
'Deviation %'
],
[
14,
30,
16,
16,
14
],
this.shiftingCandidates.map((x) => [
x.certNo,
x.scriptName,
x.purchaseRate,
x.marketPrice,
x.deviationPct
])
);
case 'portfolio':
return one(
'Portfolio',
'Investment Portfolio — Consolidated Summary',
[
'Instrument Type',
'Holdings',
'Face Value',
'Accrued Interest',
'Maturity Value'
],
[
24,
12,
18,
18,
18
],
this.portfolioSummary.map((p) => [
p.label,
p.count,
p.faceValue,
p.accrued,
p.maturityValue
])
);
default:
return one('Report', 'Investment Report', this.holdingsExportHeaders, this.holdingsExportWidths, []);
}
}
async exportExcel(): Promise<void> {
this.generating = true;
try {
await exportWorkbook(this.exportSheets(), `Investment-${this.activeReport}-${this.asOnDate}`);
this.toast('Report exported.');
} catch (err) {
console.error('Investment report export failed', err);
this.toast('Could not generate the workbook — please retry.');
} finally {
this.generating = false;
}
}
private toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
if (this.toastTimer) {
clearTimeout(this.toastTimer);
}
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
}
}
@@ -0,0 +1,71 @@
/**
* 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 { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { InvestmentDashboardComponent } from './investment-dashboard/investment-dashboard.component';
import { FdInvestmentComponent } from './fd-investment/fd-investment.component';
import { BondInvestmentComponent } from './bond-investment/bond-investment.component';
import { ShareInvestmentComponent } from './share-investment/share-investment.component';
import { InvestmentMastersComponent } from './investment-masters/investment-masters.component';
import { InvestmentReportsComponent } from './investment-reports/investment-reports.component';
import { InvestmentInterestComponent } from './investment-interest/investment-interest.component';
const routes: Routes = [
Route.withShell([
{
path: 'investment',
data: { title: 'Investment', breadcrumb: 'Investment' },
children: [
{
path: 'dashboard',
component: InvestmentDashboardComponent,
data: { title: 'Investment Dashboard', breadcrumb: 'Investment Dashboard' }
},
{
path: 'fd',
component: FdInvestmentComponent,
data: { title: 'FD Investment', breadcrumb: 'FD Investment' }
},
{
path: 'bonds',
component: BondInvestmentComponent,
data: { title: 'Govt Security / Bond Investment', breadcrumb: 'Govt Security / Bond Investment' }
},
{
path: 'share',
component: ShareInvestmentComponent,
data: { title: 'Share Investment', breadcrumb: 'Share Investment' }
},
{
path: 'masters',
component: InvestmentMastersComponent,
data: { title: 'Investment Masters', breadcrumb: 'Investment Masters' }
},
{
path: 'interest',
component: InvestmentInterestComponent,
data: { title: 'Investment Interest', breadcrumb: 'Investment Interest' }
},
{
path: 'reports',
component: InvestmentReportsComponent,
data: { title: 'Investment Reports', breadcrumb: 'Investment Reports' }
}
]
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class InvestmentRoutingModule {}
+262
View File
@@ -0,0 +1,262 @@
/**
* 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/.
*/
// Extra styles layered on top of ../cashier/cashier-shared.scss (pg-*, cctr-*) for the
// Investment module — instrument nav cards, status badges, KPI tiles, entry-form layout.
.inv-kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
@media (width <= 1100px) {
grid-template-columns: repeat(2, 1fr);
}
}
.inv-kpi {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 4px;
&__label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #64748b;
}
&__value {
font-size: 20px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #0f172a;
}
&__sub {
font-size: 11px;
color: #94a3b8;
}
}
.inv-nav-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.inv-nav-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 16px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
text-decoration: none;
color: inherit;
transition:
border-color 0.15s,
box-shadow 0.15s;
&:hover {
border-color: #0f4f3c;
box-shadow: 0 2px 10px rgb(15 79 60 / 10%);
}
&__label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #64748b;
}
&__count {
font-size: 22px;
font-weight: 800;
color: #0f172a;
}
&__value {
font-size: 13px;
font-weight: 600;
color: #0f4f3c;
}
}
.inv-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
&--active {
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
}
&--matured {
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
&--sold {
background: #fefce8;
color: #a16207;
border: 1px solid #fde68a;
}
&--written_off {
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fecaca;
}
// Investment-provisioning classification (Non-Performing Investment) — same severity ramp as
// the NPA module's stage badges, kept local since it colours a different status field here.
&--standard {
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
}
&--sub_standard {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
&--doubtful {
background: #fdf2f8;
color: #9d174d;
border: 1px solid #fbcfe8;
}
&--loss {
background: #1f2937;
color: #f9fafb;
border: 1px solid #1f2937;
}
}
.inv-form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px 20px;
padding: 18px;
}
.inv-form-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
font-weight: 600;
color: #64748b;
input,
select {
width: 100%;
height: 36px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
color: #0d1b2a;
font-family: inherit;
background: #fff;
box-sizing: border-box;
}
// Long "code — name (branch)" style option text must never run under the native dropdown
// arrow — truncate it with an ellipsis instead of letting the browser clip it mid-word.
select {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// Computed / lookup-filled fields are rendered read-only and greyed out so they read as
// derived values rather than something the user is expected to type into.
/* stylelint-disable-next-line selector-no-qualifying-type -- [readonly] only applies to inputs */
input[readonly] {
background: #f8fafc;
color: #64748b;
}
}
.inv-form-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
padding: 0 18px 18px;
}
.inv-maturity-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px dotted #eef2f7;
font-size: 13px;
&:last-child {
border-bottom: none;
}
}
.inv-maturity-days {
font-weight: 800;
font-variant-numeric: tabular-nums;
&--soon {
color: #dc2626;
}
&--ok {
color: #16a34a;
}
}
.inv-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: #0f172a;
color: #fff;
padding: 12px 20px;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
box-shadow: 0 8px 24px rgb(0 0 0 / 25%);
opacity: 0;
pointer-events: none;
transition:
opacity 0.2s,
transform 0.2s;
z-index: 2000;
&.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
+34
View File
@@ -0,0 +1,34 @@
/**
* 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 { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { InvestmentRoutingModule } from './investment-routing.module';
import { InvestmentDashboardComponent } from './investment-dashboard/investment-dashboard.component';
import { FdInvestmentComponent } from './fd-investment/fd-investment.component';
import { BondInvestmentComponent } from './bond-investment/bond-investment.component';
import { ShareInvestmentComponent } from './share-investment/share-investment.component';
import { InvestmentMastersComponent } from './investment-masters/investment-masters.component';
import { InvestmentReportsComponent } from './investment-reports/investment-reports.component';
import { InvestmentInterestComponent } from './investment-interest/investment-interest.component';
@NgModule({
imports: [
SharedModule,
InvestmentRoutingModule,
InvestmentDashboardComponent,
FdInvestmentComponent,
BondInvestmentComponent,
ShareInvestmentComponent,
InvestmentMastersComponent,
InvestmentReportsComponent,
InvestmentInterestComponent
]
})
export class InvestmentModule {}
@@ -0,0 +1,283 @@
<!--
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="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">TREASURY · INVESTMENT · SHARE</div>
<h1 class="pg-title">Share Entry</h1>
<p class="pg-sub">
Equity and cooperative-society share holdings — Purchase, Dividend, Sell and Write-off entries.
</p>
</div>
@if (activeTab === 'holdings') {
<div class="pg-toolbar">
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
</div>
}
</header>
<div class="cctr-tabs">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'holdings'"
(click)="setTab('holdings')"
>
Holdings
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'purchase'"
(click)="setTab('purchase')"
>
01 Share Purchase Entry
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'dividend'"
(click)="setTab('dividend')"
>
02 Share Interest / Dividend Entry
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'sell'"
(click)="setTab('sell')"
>
03 Share Sell Entry
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'writeoff'"
(click)="setTab('writeoff')"
>
04 Share Write Off Entry
</button>
</div>
<!-- HOLDINGS -->
@if (activeTab === 'holdings') {
<div class="pg-card">
<div class="pos-section-label">Share Holdings Register</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script</th>
<th>Party</th>
<th class="num">Units</th>
<th class="num">Rate</th>
<th class="num">Face Value</th>
<th>Purchase Date</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (h of holdings; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName }}</td>
<td>{{ h.party }}</td>
<td class="num">{{ h.units | number }}</td>
<td class="num">₹{{ h.purchaseRate | number: '1.2-2' }}</td>
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
<td>{{ h.purchaseDate }}</td>
<td>
<span [class]="statusClass(h)">{{ h.status }}</span>
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- PURCHASE ENTRY -->
@if (activeTab === 'purchase') {
<div class="pg-card">
<div class="pos-section-label">Investment - Share Purchase Entry</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Cert No</span><input type="text" [(ngModel)]="purchaseForm.certNo" readonly
/></label>
<label class="inv-form-field"
><span>Script Code</span><input type="text" [(ngModel)]="purchaseForm.scriptCode"
/></label>
<label class="inv-form-field"
><span>Script/Company Name *</span><input type="text" [(ngModel)]="purchaseForm.scriptName"
/></label>
<label class="inv-form-field"
><span>Party / Exchange</span><input type="text" [(ngModel)]="purchaseForm.party"
/></label>
<label class="inv-form-field">
<span>Sub Type</span>
<select [(ngModel)]="purchaseForm.subType">
<option value="EQUITY">EQUITY</option>
<option value="CO-OP SHARE">CO-OP SHARE</option>
</select>
</label>
<label class="inv-form-field"
><span>No. of Units *</span><input type="number" [(ngModel)]="purchaseForm.units" min="0"
/></label>
<label class="inv-form-field"
><span>Purchase Rate *</span><input type="number" [(ngModel)]="purchaseForm.purchaseRate" min="0" step="0.01"
/></label>
<label class="inv-form-field"
><span>Purchase Date *</span
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.purchaseDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Broker Code</span><input type="text" [(ngModel)]="purchaseForm.brokerCode"
/></label>
<label class="inv-form-field"
><span>CR To A/c</span><input type="text" [(ngModel)]="purchaseForm.crToAc"
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelPurchase()">Cancel</button>
<button type="button" class="pg-btn pg-btn--primary" (click)="savePurchase()">Save</button>
</div>
</div>
}
<!-- DIVIDEND ENTRY -->
@if (activeTab === 'dividend') {
<div class="pg-card">
<div class="pos-section-label">Investment - Share Interest / Dividend Entry</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Cert No *</span>
<select [(ngModel)]="dividendCertNo">
<option value="">-- Select --</option>
@for (h of activeHoldings; track h.certNo) {
<option [value]="h.certNo">{{ h.certNo }} — {{ h.scriptName }}</option>
}
</select>
</label>
@if (dividendHolding; as h) {
<label class="inv-form-field"><span>Units Held</span><input type="text" [value]="h.units" readonly /></label>
}
<label class="inv-form-field"
><span>Dividend per Share *</span><input type="number" [(ngModel)]="dividendPerShare" min="0" step="0.01"
/></label>
<label class="inv-form-field"
><span>Total Dividend (computed)</span
><input type="text" [value]="'₹' + (dividendAmount | number: '1.2-2')" readonly
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="saveDividend()">Save</button>
</div>
</div>
}
<!-- SELL ENTRY -->
@if (activeTab === 'sell') {
<div class="pg-card">
<div class="pos-section-label">Investment - Share Sell Entry</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Cert No *</span>
<select [(ngModel)]="sellCertNo">
<option value="">-- Select --</option>
@for (h of activeHoldings; track h.certNo) {
<option [value]="h.certNo">{{ h.certNo }} — {{ h.scriptName }}</option>
}
</select>
</label>
@if (sellHolding; as h) {
<label class="inv-form-field"><span>Units Held</span><input type="text" [value]="h.units" readonly /></label>
}
<label class="inv-form-field"
><span>Units to Sell *</span><input type="number" [(ngModel)]="sellUnits" min="0"
/></label>
<label class="inv-form-field"
><span>Sell Rate *</span><input type="number" [(ngModel)]="sellRate" min="0" step="0.01"
/></label>
<label class="inv-form-field"
><span>Consideration (computed)</span
><input type="text" [value]="'₹' + (sellConsideration | number: '1.2-2')" readonly
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="saveSell()">Save</button>
</div>
</div>
}
<!-- WRITE OFF ENTRY -->
@if (activeTab === 'writeoff') {
<div class="pg-card">
<div class="pos-section-label">Investment - Share Write Off Entry</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Cert No *</span>
<select [(ngModel)]="writeoffCertNo">
<option value="">-- Select --</option>
@for (h of activeHoldings; track h.certNo) {
<option [value]="h.certNo">{{ h.certNo }} — {{ h.scriptName }}</option>
}
</select>
</label>
@if (writeoffHolding; as h) {
<label class="inv-form-field"
><span>Face Value to be Written Off</span
><input type="text" [value]="'₹' + (h.faceValue | number: '1.2-2')" readonly
/></label>
}
<label class="inv-form-field"><span>Reason *</span><input type="text" [(ngModel)]="writeoffReason" /></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="saveWriteoff()">Save</button>
</div>
</div>
}
@if (activeTab !== 'holdings') {
<div class="pg-card">
<div class="pos-section-label">Share Transaction Log</div>
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>Voucher No</th>
<th>Cert No</th>
<th>Type</th>
<th>Particulars</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (t of txns; track t.voucherNo) {
<tr>
<td>{{ t.txnDate }}</td>
<td>
<span class="pg-chip">{{ t.voucherNo }}</span>
</td>
<td>{{ t.certNo }}</td>
<td>{{ t.txnType }}</td>
<td>{{ t.particulars }}</td>
<td class="num">₹{{ t.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
@@ -0,0 +1,320 @@
/**
* 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 } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import { SHARE_HOLDINGS, ALL_TXNS, InvestmentHolding, InvestmentTxn, todayDDMMYYYY } from '../investment-data';
import { exportWorkbook } from '../investment-export';
type ShareTab = 'holdings' | 'purchase' | 'dividend' | 'sell' | 'writeoff';
interface SharePurchaseForm {
certNo: string;
scriptCode: string;
scriptName: string;
party: string;
subType: string;
units: number | null;
purchaseRate: number | null;
purchaseDate: string;
brokerCode: string;
crToAc: string;
}
/**
* Share Entry treasury screen — Holdings register + Purchase / Dividend / Sell / Write-off
* entry tabs (legacy "02 Share Entry" menu). Dummy in-memory data; Save appends locally.
*/
@Component({
selector: 'mifosx-share-investment',
templateUrl: './share-investment.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../investment-shared.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
DateDdmmyyyyPickerComponent
]
})
export class ShareInvestmentComponent {
activeTab: ShareTab = 'holdings';
asOnDate = todayDDMMYYYY();
holdings: InvestmentHolding[] = [...SHARE_HOLDINGS];
txns: InvestmentTxn[] = ALL_TXNS.filter((t) => t.instrumentType === 'SHARE');
generating = false;
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
purchaseForm: SharePurchaseForm = this.blankPurchaseForm();
dividendCertNo = '';
dividendPerShare: number | null = null;
sellCertNo = '';
sellUnits: number | null = null;
sellRate: number | null = null;
writeoffCertNo = '';
writeoffReason = '';
setTab(tab: ShareTab): void {
this.activeTab = tab;
}
get activeHoldings(): InvestmentHolding[] {
return this.holdings.filter((h) => h.status === 'ACTIVE');
}
private blankPurchaseForm(): SharePurchaseForm {
return {
certNo: `SH-${6000 + this.holdings.length + 1}`,
scriptCode: '',
scriptName: '',
party: 'NSE/BSE',
subType: 'EQUITY',
units: null,
purchaseRate: null,
purchaseDate: this.asOnDate,
brokerCode: '',
crToAc: '310070'
};
}
savePurchase(): void {
const f = this.purchaseForm;
if (!f.scriptName || !f.units || !f.purchaseRate) {
this.toast('Please fill Script Name, No. of Units and Purchase Rate.');
return;
}
const faceValue = f.units * f.purchaseRate;
const holding: InvestmentHolding = {
certNo: f.certNo,
instrumentType: 'SHARE',
scriptCode: f.scriptCode || f.scriptName,
scriptName: f.scriptName,
party: f.party,
subType: f.subType,
faceValue,
purchaseRate: f.purchaseRate,
units: f.units,
purchaseDate: f.purchaseDate,
maturityDate: f.purchaseDate,
interestRate: 0,
interestFreq: 'YEARLY',
glCode: '310060',
glName: 'INVESTMENT IN SHARES',
crToAc: f.crToAc,
brokerCode: f.brokerCode,
status: 'ACTIVE'
};
this.holdings = [
holding,
...this.holdings
];
this.txns = [
{
certNo: holding.certNo,
instrumentType: 'SHARE',
txnType: 'PURCHASE',
voucherNo: `INV/PUR/${holding.certNo}`,
txnDate: f.purchaseDate,
amount: faceValue,
particulars: `Purchase of ${f.units} shares - ${holding.scriptName}`
},
...this.txns
];
this.toast(`Share holding ${holding.certNo} saved successfully.`);
this.purchaseForm = this.blankPurchaseForm();
this.activeTab = 'holdings';
}
cancelPurchase(): void {
this.purchaseForm = this.blankPurchaseForm();
}
// -------------------- Dividend --------------------
get dividendHolding(): InvestmentHolding | undefined {
return this.holdings.find((h) => h.certNo === this.dividendCertNo);
}
get dividendAmount(): number {
const h = this.dividendHolding;
if (!h || !this.dividendPerShare) {
return 0;
}
return Math.round(h.units * this.dividendPerShare);
}
saveDividend(): void {
const h = this.dividendHolding;
if (!h || !this.dividendPerShare) {
this.toast('Select a holding and enter dividend per share.');
return;
}
const amt = this.dividendAmount;
this.txns = [
{
certNo: h.certNo,
instrumentType: 'SHARE',
txnType: 'DIVIDEND',
voucherNo: `INV/DIV/${h.certNo}-${this.txns.length + 1}`,
txnDate: this.asOnDate,
amount: amt,
particulars: `Dividend received - ${h.scriptName} (₹${this.dividendPerShare}/share)`
},
...this.txns
];
this.toast(`Dividend of ₹${amt.toLocaleString('en-IN')} recorded for ${h.certNo}.`);
this.dividendCertNo = '';
this.dividendPerShare = null;
}
// -------------------- Sell --------------------
get sellHolding(): InvestmentHolding | undefined {
return this.holdings.find((h) => h.certNo === this.sellCertNo);
}
get sellConsideration(): number {
if (!this.sellUnits || !this.sellRate) {
return 0;
}
return Math.round(this.sellUnits * this.sellRate);
}
saveSell(): void {
const h = this.sellHolding;
if (!h || !this.sellUnits || !this.sellRate || this.sellUnits > h.units) {
this.toast('Select a holding and enter valid units (≤ held) and sell rate.');
return;
}
const consideration = this.sellConsideration;
h.units -= this.sellUnits;
h.faceValue = Math.round(h.units * h.purchaseRate);
if (h.units === 0) {
h.status = 'SOLD';
}
this.txns = [
{
certNo: h.certNo,
instrumentType: 'SHARE',
txnType: 'SELL',
voucherNo: `INV/SEL/${h.certNo}-${this.txns.length + 1}`,
txnDate: this.asOnDate,
amount: consideration,
particulars: `Sold ${this.sellUnits} shares of ${h.scriptName} @ ${this.sellRate}`
},
...this.txns
];
this.toast(`Sold ${this.sellUnits} shares. Consideration ₹${consideration.toLocaleString('en-IN')}.`);
this.sellCertNo = '';
this.sellUnits = null;
this.sellRate = null;
this.activeTab = 'holdings';
}
// -------------------- Write-off --------------------
get writeoffHolding(): InvestmentHolding | undefined {
return this.holdings.find((h) => h.certNo === this.writeoffCertNo);
}
saveWriteoff(): void {
const h = this.writeoffHolding;
if (!h || !this.writeoffReason) {
this.toast('Select a holding and enter a write-off reason.');
return;
}
h.status = 'WRITTEN_OFF';
this.txns = [
{
certNo: h.certNo,
instrumentType: 'SHARE',
txnType: 'WRITE_OFF',
voucherNo: `INV/WOF/${h.certNo}`,
txnDate: this.asOnDate,
amount: h.faceValue,
particulars: `Written off - ${this.writeoffReason}`
},
...this.txns
];
this.toast(`${h.certNo} written off.`);
this.writeoffCertNo = '';
this.writeoffReason = '';
this.activeTab = 'holdings';
}
statusClass(h: InvestmentHolding): string {
return `inv-badge inv-badge--${h.status.toLowerCase()}`;
}
// -------------------- Export to Excel (Holdings register) --------------------
async exportExcel(): Promise<void> {
this.generating = true;
try {
await exportWorkbook(
[
{
sheetName: 'Share Holdings',
title: 'Share Holdings Register',
subtitle: `As on ${this.asOnDate}`,
headers: [
'Cert No',
'Script',
'Party',
'Units',
'Purchase Rate',
'Face Value',
'Purchase Date',
'Status'
],
widths: [
14,
24,
22,
12,
14,
16,
14,
12
],
rows: this.holdings.map((h) => [
h.certNo,
h.scriptName,
h.party,
h.units,
h.purchaseRate,
h.faceValue,
h.purchaseDate,
h.status
])
}
],
`Share-Holdings-${this.asOnDate}`
);
this.toast('Share Holdings exported.');
} catch (err) {
console.error('Share Holdings export failed', err);
this.toast('Could not generate the workbook — please retry.');
} finally {
this.generating = false;
}
}
private toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
if (this.toastTimer) {
clearTimeout(this.toastTimer);
}
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
}
}
@@ -0,0 +1,878 @@
<!--
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="pg">
@if (application; as a) {
<header class="pg-head">
<div>
<div class="pg-eyebrow">CREDIT · LOAN ORIGINATION · {{ a.branchName | uppercase }}</div>
<h1 class="pg-title">
{{ a.applicationNo }} — {{ a.applicantName }}
<span [class]="badgeClass(a)">{{ statusLabels[a.overallStatus] }}</span>
</h1>
<p class="pg-sub npa-report-note">
{{ a.productName }} · ₹{{ a.requestedAmount | number: '1.0-0' }} · Applied {{ a.applicationDate }} ·
{{ progress(a) }}% through the sanctioning workflow.
</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn pg-btn--ghost" (click)="goBack()">‹ Back to Pipeline</button>
</div>
</header>
<div class="pg-card">
<dl class="loan-kv-grid">
<div class="loan-kv-field">
<dt>Branch</dt>
<dd>{{ a.branchName }}</dd>
</div>
<div class="loan-kv-field">
<dt>Channel</dt>
<dd>
{{ a.channel === 'BC' ? a.bcAgentName : a.channel === 'SELF_APPLY' ? 'Self-Apply (Digital)' : 'Branch' }}
</dd>
</div>
<div class="loan-kv-field">
<dt>Customer Type</dt>
<dd>{{ a.customerType === 'EXISTING' ? 'Existing Customer' : 'New Customer' }}</dd>
</div>
<div class="loan-kv-field">
<dt>Tenure / Rate</dt>
<dd>{{ a.tenureMonths }} months @ {{ a.interestRate }}%</dd>
</div>
<div class="loan-kv-field">
<dt>Monthly Income / Eligible Amount</dt>
<dd>₹{{ a.monthlyIncome | number: '1.0-0' }} / ₹{{ a.eligibleLoanAmount | number: '1.0-0' }}</dd>
</div>
<div class="loan-kv-field">
<dt>Sanctioning Authority</dt>
<dd>{{ sanctioningAuthorityOf(a) }}</dd>
</div>
<div class="loan-kv-field">
<dt>Share Member</dt>
<dd>
@if (a.isShareMember) {
Yes — {{ a.shareCertNo }} (since {{ a.shareMemberSince }})
} @else {
<span class="loan-badge loan-badge--hold">Not a Member</span>
<button type="button" class="pg-btn pg-btn--ghost" (click)="allotMembership(a)">Allot Shares</button>
}
</dd>
</div>
@if (a.coBorrowerRequired) {
<div class="loan-kv-field">
<dt>Co-Borrower</dt>
<dd>{{ a.coBorrowerName }}</dd>
</div>
}
@if (a.guarantorRequired) {
<div class="loan-kv-field">
<dt>Guarantor</dt>
<dd>{{ a.guarantorName }}</dd>
</div>
}
</dl>
</div>
<div class="pg-card">
<div class="pos-section-label">Sanctioning Workflow Progress</div>
<div class="loan-stepper">
@for (s of stageOrder; track s) {
<div [class]="stepStatusClass(a, s)">
<span class="loan-step__connector"></span>
<span class="loan-step__dot">{{ stepMarker(a, s) }}</span>
<span class="loan-step__label">{{ stageLabels[s] }}</span>
</div>
}
</div>
</div>
@if (a.overallStatus === 'REJECTED') {
<div class="pg-card">
<div class="pos-section-label">Application Rejected — at {{ stageLabels[a.currentStage] }}</div>
<p class="pg-sub npa-report-note">See the approval summary below for the recorded reason.</p>
</div>
} @else if (a.overallStatus === 'ON_HOLD') {
<div class="pg-card">
<div class="pos-section-label">Personal Discussion — {{ discrepancyLabels[a.discrepancyType] }}</div>
<div class="loan-action-card">
<p>{{ a.personalDiscussionNotes }}</p>
<label class="inv-form-field">
<span>Resolution Notes</span>
<input type="text" [(ngModel)]="actionRemarks" placeholder="How was this resolved?" />
</label>
<div class="loan-action-row">
<button type="button" class="pg-btn pg-btn--success" (click)="resolveDiscrepancy(a)">
Resolve &amp; Proceed to Loan Committee
</button>
</div>
</div>
</div>
} @else if (a.overallStatus === 'DISBURSED') {
<div class="pg-card">
<div class="pos-section-label">Disbursed</div>
<dl class="loan-kv-grid">
<div class="loan-kv-field">
<dt>Disbursement Date</dt>
<dd>{{ a.disbursementDate }}</dd>
</div>
<div class="loan-kv-field">
<dt>Mode</dt>
<dd>{{ a.disbursementMode }}</dd>
</div>
<div class="loan-kv-field">
<dt>ACH Confirmation</dt>
<dd>{{ a.achConfirmationRef }}</dd>
</div>
<div class="loan-kv-field">
<dt>EMI Activation Confirmation</dt>
<dd>{{ a.emiActivationConfirmed ? 'Confirmed on ' + a.emiActivationDate : 'Pending' }}</dd>
</div>
</dl>
</div>
<div class="pg-card">
<div class="pos-section-label">EMI Repayment Schedule</div>
@if (isRevolving(a)) {
<p class="pg-sub npa-report-note">
{{ a.productName }} is a revolving/renewable limit — repaid on demand and renewed annually, not on a fixed
EMI schedule.
</p>
} @else {
<div class="inv-kpis npa-kpis--ratio">
<div class="inv-kpi">
<span class="inv-kpi__label">Loan Amount</span>
<span class="inv-kpi__value">₹{{ a.requestedAmount | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">{{ a.tenureMonths }} months @ {{ a.interestRate }}%</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Monthly EMI</span>
<span class="inv-kpi__value">₹{{ emiAmountFor(a) | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">Reducing balance</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Total Interest Payable</span>
<span class="inv-kpi__value">₹{{ totalInterestFor(a) | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">over the full tenure</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Total Amount Payable</span>
<span class="inv-kpi__value">₹{{ totalPayableFor(a) | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">principal + interest</span>
</div>
</div>
<div class="pg-table-scroll loan-emi-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th class="num">Inst. No</th>
<th>Due Date</th>
<th class="num">Opening Balance</th>
<th class="num">EMI</th>
<th class="num">Principal</th>
<th class="num col-out">Interest</th>
<th class="num">Closing Balance</th>
</tr>
</thead>
<tbody>
@for (row of emiScheduleFor(a); track row.installmentNo) {
<tr>
<td class="num">{{ row.installmentNo }}</td>
<td>{{ row.dueDate }}</td>
<td class="num">₹{{ row.openingBalance | number: '1.0-0' }}</td>
<td class="num">₹{{ row.emiAmount | number: '1.0-0' }}</td>
<td class="num col-in">₹{{ row.principalComponent | number: '1.0-0' }}</td>
<td class="num col-out">₹{{ row.interestComponent | number: '1.0-0' }}</td>
<td class="num">₹{{ row.closingBalance | number: '1.0-0' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
} @else {
<!-- ===================== APPLICATION ===================== -->
@if (a.currentStage === 'APPLICATION') {
<div class="pg-card">
<div class="pos-section-label">Application Received</div>
<div class="loan-action-card">
<p>
Received via
{{ a.channel === 'BC' ? a.bcAgentName : a.channel === 'SELF_APPLY' ? 'Self-Apply (Digital)' : 'Branch' }}
on {{ a.applicationDate }}.
</p>
<div class="loan-action-row">
<button
type="button"
class="pg-btn pg-btn--primary"
(click)="proceed(a, 'Application logged and forwarded for KYC.')"
>
Proceed to KYC &amp; Document Collection
</button>
</div>
</div>
</div>
}
<!-- ===================== KYC & DOCUMENTS ===================== -->
@if (a.currentStage === 'KYC_DOCUMENTS') {
<div class="pg-card">
<div class="pos-section-label">KYC &amp; Document Collection — OVD (RBI KYC Master Direction)</div>
<div class="loan-action-card">
<div class="loan-checklist">
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.aadhaarVerified" /> Aadhaar Verified (OVD)
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.panVerified" /> PAN Verified
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.incomeProofSubmitted" /> Income Proof Submitted
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.bankStatementSubmitted" /> Bank Statement Submitted
</label>
@if (a.secured) {
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.propertyDetailsSubmitted" /> Property Details Submitted
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.taxReceiptSubmitted" /> Property Tax Receipt Submitted
</label>
}
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.electricityBillSubmitted" /> Electricity Bill (Address Proof)
Submitted
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.aadhaarFingerprintCaptured" /> Aadhaar Fingerprint Captured
(e-KYC)
</label>
</div>
@if (a.monthlyIncome === 0) {
<label class="inv-form-field">
<span>Monthly Income (₹)</span>
<input type="number" [(ngModel)]="monthlyIncomeInput" placeholder="e.g. 45000" />
</label>
<p class="pg-sub npa-report-note">
Eligible Loan Amount preview: ₹{{ eligiblePreview(a) | number: '1.0-0' }}
</p>
<div class="loan-action-row">
<button type="button" class="pg-btn pg-btn--ghost" (click)="setIncomeNow(a)">
Assess Income &amp; Calculate Eligible Amount
</button>
</div>
} @else {
<dl class="loan-kv-grid" style="padding: 0 0 14px">
<div class="loan-kv-field">
<dt>Monthly Income</dt>
<dd>₹{{ a.monthlyIncome | number: '1.0-0' }}</dd>
</div>
<div class="loan-kv-field">
<dt>Eligible Loan Amount</dt>
<dd>₹{{ a.eligibleLoanAmount | number: '1.0-0' }}</dd>
</div>
</dl>
}
@if (a.coBorrowerRequired) {
<p class="pg-sub npa-report-note">Co-Borrower Required — {{ a.coBorrowerName }}</p>
}
<div class="loan-action-row">
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="!allKycDone(a)"
(click)="proceed(a, 'KYC/OVD documents verified and collected.')"
>
Proceed to CIC / Credit Bureau Check
</button>
</div>
</div>
</div>
}
<!-- ===================== CIC / CREDIT BUREAU CHECK ===================== -->
@if (a.currentStage === 'CIC_CHECK') {
<div class="pg-card">
<div class="pos-section-label">CIC / Credit Bureau Check — {{ a.cicBureau }}</div>
<div class="loan-action-card">
@if (a.cibilScore === 0) {
<label class="inv-form-field">
<span>Bureau</span>
<select [(ngModel)]="a.cicBureau">
@for (bureau of cicBureaus; track bureau) {
<option [value]="bureau">{{ bureau }}</option>
}
</select>
</label>
<div class="loan-action-row">
<button type="button" class="pg-btn pg-btn--primary" (click)="checkCreditScoreNow(a)">
Check Credit Score
</button>
</div>
} @else {
<dl class="loan-kv-grid" style="padding: 0 0 14px">
<div class="loan-kv-field">
<dt>Bureau</dt>
<dd>{{ a.cicBureau }}</dd>
</div>
<div class="loan-kv-field">
<dt>Credit Score</dt>
<dd>{{ a.cibilScore }} ({{ cibilBandOf(a.cibilScore) }})</dd>
</div>
<div class="loan-kv-field">
<dt>Report</dt>
<dd>{{ a.cicReportUploaded ? a.cicReportFileName : 'Not uploaded yet' }}</dd>
</div>
<div class="loan-kv-field">
<dt>Field Inquiry</dt>
<dd>
{{
a.fiRequestPushed
? 'Pushed to ' + a.fiAgencyAssigned + ' (' + a.fiRequestDate + ')'
: 'Not pushed yet'
}}
</dd>
</div>
</dl>
}
@if (a.cibilScore > 0) {
@if (!a.cicReportUploaded) {
<div class="loan-action-row">
<label class="pg-btn pg-btn--ghost" style="cursor: pointer">
Upload {{ a.cicBureau }} Report
<input type="file" hidden accept=".pdf,.jpg,.jpeg,.png" (change)="onCicFileSelected($event, a)" />
</label>
</div>
} @else if (!a.fiRequestPushed) {
<div class="loan-action-row">
<button type="button" class="pg-btn pg-btn--ghost" (click)="pushFieldInquiryNow(a)">
Push Field Inquiry to Agency
</button>
</div>
} @else {
<div class="loan-action-row">
<button
type="button"
class="pg-btn pg-btn--primary"
(click)="
proceed(
a,
a.cicBureau +
' report reviewed — score ' +
a.cibilScore +
' (' +
cibilBandOf(a.cibilScore) +
').'
)
"
>
Proceed to Field Investigation
</button>
</div>
}
}
</div>
</div>
}
<!-- ===================== FIELD INVESTIGATION ===================== -->
@if (a.currentStage === 'FIELD_INVESTIGATION') {
<div class="pg-card">
<div class="pos-section-label">Field Investigation (FI) — Agent Report</div>
<div class="loan-action-card">
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Address Verification</span>
<select [(ngModel)]="a.addressVerification.status">
<option value="PENDING">Pending</option>
<option value="VERIFIED">Verified</option>
<option value="FAILED">Failed</option>
</select>
</label>
<label class="inv-form-field">
<span>Office / Employment Verification</span>
<select [(ngModel)]="a.officeVerification.status">
<option value="PENDING">Pending</option>
<option value="VERIFIED">Verified</option>
<option value="FAILED">Failed</option>
</select>
</label>
@if (a.secured) {
<label class="inv-form-field">
<span>Property Verification / Valuation</span>
<select [(ngModel)]="a.propertyVerification.status">
<option value="PENDING">Pending</option>
<option value="VERIFIED">Verified</option>
<option value="FAILED">Failed</option>
</select>
</label>
<label class="inv-form-field">
<span>Property Valuation (₹)</span>
<input type="number" [(ngModel)]="a.propertyValuation" />
</label>
}
</div>
<div class="loan-kv-grid" style="padding: 0 0 14px">
<div class="loan-kv-field">
<dt>Agent Report</dt>
<dd>{{ a.agentReportUploaded ? a.agentReportFileName : 'Not uploaded yet' }}</dd>
</div>
</div>
@if (!a.agentReportUploaded) {
<div class="loan-action-row">
<label class="pg-btn pg-btn--ghost" style="cursor: pointer">
Upload Agent Report
<input
type="file"
hidden
accept=".pdf,.jpg,.jpeg,.png"
(change)="onAgentReportFileSelected($event, a)"
/>
</label>
</div>
}
<div class="loan-action-row">
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="!fiComplete(a) || !a.agentReportUploaded"
(click)="completeFi(a)"
>
Record FI &amp; Proceed to Personal Discussion
</button>
</div>
</div>
</div>
}
<!-- ===================== PERSONAL DISCUSSION ===================== -->
@if (a.currentStage === 'PERSONAL_DISCUSSION') {
<div class="pg-card">
<div class="pos-section-label">Personal Discussion with Manager</div>
<div class="loan-action-card">
<label class="inv-form-field">
<span>Discrepancy (if any)</span>
<select [(ngModel)]="discrepancySelection">
<option value="NONE">No Discrepancy</option>
<option value="DOCUMENT_DISCREPANCY">Document Discrepancy</option>
<option value="NAME_MISMATCH">Name Mismatch</option>
<option value="OFFICE_VERIFICATION_FAILED">Office Verification Failed</option>
<option value="PROPERTY_DISCREPANCY">Property Discrepancy</option>
<option value="FI_REPORT_DISCREPANCY">FI Report Discrepancy</option>
<option value="FAKE_DECLARATION">Fake Declaration Suspected</option>
</select>
</label>
<label class="inv-form-field">
<span>Discussion Notes</span>
<input type="text" [(ngModel)]="actionRemarks" placeholder="Notes from discussion with manager" />
</label>
<div class="loan-action-row">
@if (discrepancySelection === 'NONE') {
<button type="button" class="pg-btn pg-btn--success" (click)="clearPersonalDiscussion(a)">
Clear, Take Declaration &amp; Proceed to Loan Committee
</button>
} @else {
<button type="button" class="pg-btn pg-btn--warn" (click)="flagPersonalDiscussion(a)">
Flag Discrepancy — Hold Application
</button>
}
</div>
</div>
</div>
}
<!-- ===================== LOAN COMMITTEE APPROVAL ===================== -->
@if (a.currentStage === 'CREDIT_COMMITTEE') {
<div class="pg-card">
<div class="pos-section-label">Loan Committee Approval — {{ sanctioningAuthorityOf(a) }}</div>
<div class="loan-action-card">
<p class="pg-sub npa-report-note">Reports Required — present below reports, then complete.</p>
<div class="loan-checklist">
<label class="loan-checklist-item">
<input type="checkbox" [ngModel]="a.cicReportUploaded" disabled /> CIBIL / Credit Bureau Report
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.backgroundCheckDone" /> Background Check
</label>
@if (a.secured) {
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.cersaiSearchDone" /> CSE — CERSAI Search Enquiry
</label>
}
<label class="loan-checklist-item">
<input type="checkbox" [ngModel]="a.aadhaarVerified && a.panVerified" disabled /> OVD
</label>
<label class="loan-checklist-item">
<input type="checkbox" [ngModel]="a.incomeProofSubmitted" disabled /> Income Proof
</label>
<label class="loan-checklist-item">
<input type="checkbox" [ngModel]="fiComplete(a)" disabled /> FI Report
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.managerMeetingDone" /> Manager Meeting
</label>
<label class="loan-checklist-item">
<input type="checkbox" [ngModel]="a.isShareMember" disabled /> Share Member Approval
</label>
@if (a.secured) {
<label class="loan-checklist-item">
<input type="checkbox" [ngModel]="a.propertyValuation > 0" disabled /> Property Valuation
</label>
}
</div>
<label class="inv-form-field">
<span>Committee Remarks</span>
<input type="text" [(ngModel)]="actionRemarks" placeholder="Remarks" />
</label>
<div class="loan-action-row">
<button
type="button"
class="pg-btn pg-btn--success"
[disabled]="!committeeReportsReady(a)"
(click)="approveStage(a, 'creditCommitteeApproval', 'Sanctioning Authority')"
>
Approve
</button>
<button
type="button"
class="pg-btn pg-btn--danger"
(click)="rejectStage(a, 'creditCommitteeApproval', 'Sanctioning Authority')"
>
Reject
</button>
</div>
</div>
</div>
}
<!-- ===================== RISK APPROVAL ===================== -->
@if (a.currentStage === 'RISK_APPROVAL') {
<div class="pg-card">
<div class="pos-section-label">Risk Approval — Verification Checklist</div>
<div class="loan-action-card">
<div class="loan-checklist">
<label class="loan-checklist-item">
<input type="checkbox" [ngModel]="a.addressVerification.status === 'VERIFIED'" disabled /> ID / Address
Verified
</label>
<label class="loan-checklist-item">
<input type="checkbox" [ngModel]="a.incomeProofSubmitted" disabled /> Income Proof Verified
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.previousLoanDeclarationDone" /> Previous Loan Declaration
</label>
@if (a.secured) {
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.propertySearchReportDone" /> Property Search Report
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.valuationReportConfirmed" /> Valuation Report
</label>
}
<label class="loan-checklist-item">
<input type="checkbox" [ngModel]="fiComplete(a)" disabled /> FI Report
</label>
<label class="loan-checklist-item">
<input type="checkbox" [ngModel]="a.backgroundCheckDone" disabled /> Personal Background Check
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.guarantorRequired" /> Guarantor Required
</label>
</div>
@if (a.guarantorRequired) {
<label class="inv-form-field">
<span>Guarantor Name</span>
<input type="text" [(ngModel)]="a.guarantorName" />
</label>
}
<label class="inv-form-field">
<span>Risk Remarks</span>
<input
type="text"
[(ngModel)]="actionRemarks"
placeholder="ID/address, income adequacy, previous-loan declaration"
/>
</label>
<div class="loan-action-row">
<button
type="button"
class="pg-btn pg-btn--success"
[disabled]="!riskChecklistReady(a)"
(click)="approveStage(a, 'riskApproval', 'Risk Approval')"
>
Approve
</button>
<button
type="button"
class="pg-btn pg-btn--danger"
(click)="rejectStage(a, 'riskApproval', 'Risk Approval')"
>
Reject
</button>
</div>
</div>
</div>
}
<!-- ===================== LEGAL APPROVAL ===================== -->
@if (a.currentStage === 'LEGAL_APPROVAL') {
<div class="pg-card">
<div class="pos-section-label">Legal Approval — Fees &amp; A/c Opened By</div>
<div class="loan-action-card">
<div class="loan-checklist">
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.feesAndAccountOpened" /> Fees &amp; A/c Opening Done
</label>
@if (a.secured) {
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.mortgageCreated" /> Mortgage Created
</label>
}
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.legalStampDone" /> Legal Stamp Done
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.fileFilingDone" /> File Filing Done
</label>
</div>
<label class="inv-form-field">
<span>Agreement Signing Slot</span>
<input type="text" [(ngModel)]="a.agreementSigningSlot" placeholder="DD-MM-YYYY" />
</label>
<label class="inv-form-field">
<span>Legal Remarks</span>
<input type="text" [(ngModel)]="actionRemarks" placeholder="Remarks" />
</label>
<div class="loan-action-row">
<button
type="button"
class="pg-btn pg-btn--success"
[disabled]="!legalChecklistDone(a)"
(click)="approveStage(a, 'legalApproval', 'Legal Approval')"
>
Approve &amp; Allot Signing Slot
</button>
<button
type="button"
class="pg-btn pg-btn--danger"
(click)="rejectStage(a, 'legalApproval', 'Legal Approval')"
>
Reject
</button>
</div>
</div>
</div>
}
<!-- ===================== CUSTOMER SIGNING SLOT ===================== -->
@if (a.currentStage === 'CUSTOMER_SIGNING') {
<div class="pg-card">
<div class="pos-section-label">Customer Signing Slot — {{ a.agreementSigningSlot }}</div>
<div class="loan-action-card">
<p>Loan Agreement and Sanction Letter ready for the customer's acknowledgement and signature.</p>
<div class="loan-action-row">
<button type="button" class="pg-btn pg-btn--primary" (click)="markSigned(a)">
Mark Signed by Customer
</button>
</div>
</div>
</div>
}
<!-- ===================== CHEQUE RECEIVED / ACH SIGN ===================== -->
@if (a.currentStage === 'CHEQUE_ACH') {
<div class="pg-card">
<div class="pos-section-label">Cheque Received / ACH Sign</div>
<div class="loan-action-card">
<div class="loan-checklist">
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.chequeReceived" /> Cheque Received
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.achSigned" /> ACH Mandate Signed
</label>
</div>
<div class="loan-action-row">
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="!a.chequeReceived || !a.achSigned"
(click)="confirmChequeAndProceed(a)"
>
Confirm &amp; Proceed to Post-Disbursement Check
</button>
</div>
</div>
</div>
}
<!-- ===================== POST-DISBURSEMENT CHECK ===================== -->
@if (a.currentStage === 'POST_CHECK') {
<div class="pg-card">
<div class="pos-section-label">Post-Disbursement Check — Final Compliance Gate</div>
<div class="loan-action-card">
<p class="pg-sub npa-report-note">Document Signing by:</p>
<div class="loan-checklist">
<label class="loan-checklist-item"> <input type="checkbox" [(ngModel)]="a.signedByAgent" /> Agent </label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.signedByManager" /> Manager
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.signedByCommittee" /> Committee
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.signedByRiskApproval" /> Risk Approval
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.signedByLegalApproval" /> Legal Approval
</label>
</div>
<label class="inv-form-field">
<span>Remarks</span>
<input type="text" [(ngModel)]="actionRemarks" placeholder="Remarks" />
</label>
<div class="loan-action-row">
<button
type="button"
class="pg-btn pg-btn--success"
[disabled]="!postCheckSignaturesDone(a)"
(click)="approveStage(a, 'postCheckApproval', 'Post-Disbursement Check')"
>
Clear for Disbursement
</button>
<button type="button" class="pg-btn pg-btn--warn" (click)="exceptionApprove(a)">
Approve on Exception
</button>
<button
type="button"
class="pg-btn pg-btn--danger"
(click)="rejectStage(a, 'postCheckApproval', 'Post-Disbursement Check')"
>
Reject
</button>
</div>
</div>
</div>
}
<!-- ===================== DISBURSEMENT ===================== -->
@if (a.currentStage === 'DISBURSED') {
<div class="pg-card">
<div class="pos-section-label">Ready for Disbursement</div>
<div class="loan-action-card">
<label class="inv-form-field">
<span>Disbursement Mode</span>
<select [(ngModel)]="disbursementMode">
<option value="Account Credit">Account Credit</option>
<option value="NEFT">NEFT</option>
<option value="RTGS">RTGS</option>
</select>
</label>
<p class="pg-sub npa-report-note">
Disbursing also confirms EMI Activation for this account in the same step.
</p>
<div class="loan-action-row">
<button type="button" class="pg-btn pg-btn--success" (click)="disburseNow(a)">
Disburse Now &amp; Confirm EMI Activation
</button>
</div>
</div>
</div>
}
}
<div class="pg-card">
<div class="pos-section-label">Approval Summary</div>
<div class="loan-approval-summary">
<div
class="loan-approval-tile"
[class.loan-approval-tile--approved]="
a.creditCommitteeApproval.decision === 'APPROVED' ||
a.creditCommitteeApproval.decision === 'EXCEPTION_APPROVED'
"
[class.loan-approval-tile--rejected]="a.creditCommitteeApproval.decision === 'REJECTED'"
[class.loan-approval-tile--pending]="a.creditCommitteeApproval.decision === 'PENDING'"
>
<span class="loan-approval-tile__role">Loan Committee</span>
<span class="loan-approval-tile__decision">{{ a.creditCommitteeApproval.decision }}</span>
<span>{{ a.creditCommitteeApproval.approverName }} · {{ a.creditCommitteeApproval.date }}</span>
<span>{{ a.creditCommitteeApproval.remarks }}</span>
</div>
<div
class="loan-approval-tile"
[class.loan-approval-tile--approved]="
a.riskApproval.decision === 'APPROVED' || a.riskApproval.decision === 'EXCEPTION_APPROVED'
"
[class.loan-approval-tile--rejected]="a.riskApproval.decision === 'REJECTED'"
[class.loan-approval-tile--pending]="a.riskApproval.decision === 'PENDING'"
>
<span class="loan-approval-tile__role">Risk Approval</span>
<span class="loan-approval-tile__decision">{{ a.riskApproval.decision }}</span>
<span>{{ a.riskApproval.approverName }} · {{ a.riskApproval.date }}</span>
<span>{{ a.riskApproval.remarks }}</span>
</div>
<div
class="loan-approval-tile"
[class.loan-approval-tile--approved]="
a.legalApproval.decision === 'APPROVED' || a.legalApproval.decision === 'EXCEPTION_APPROVED'
"
[class.loan-approval-tile--rejected]="a.legalApproval.decision === 'REJECTED'"
[class.loan-approval-tile--pending]="a.legalApproval.decision === 'PENDING'"
>
<span class="loan-approval-tile__role">Legal Approval</span>
<span class="loan-approval-tile__decision">{{ a.legalApproval.decision }}</span>
<span>{{ a.legalApproval.approverName }} · {{ a.legalApproval.date }}</span>
<span>{{ a.legalApproval.remarks }}</span>
</div>
<div
class="loan-approval-tile"
[class.loan-approval-tile--approved]="
a.postCheckApproval.decision === 'APPROVED' || a.postCheckApproval.decision === 'EXCEPTION_APPROVED'
"
[class.loan-approval-tile--rejected]="a.postCheckApproval.decision === 'REJECTED'"
[class.loan-approval-tile--pending]="a.postCheckApproval.decision === 'PENDING'"
>
<span class="loan-approval-tile__role">Post-Disbursement Check</span>
<span class="loan-approval-tile__decision">{{ a.postCheckApproval.decision }}</span>
<span>{{ a.postCheckApproval.approverName }} · {{ a.postCheckApproval.date }}</span>
<span>{{ a.postCheckApproval.remarks }}</span>
</div>
</div>
</div>
<div class="pg-card">
<div class="pos-section-label">Application Journey — Date-wise Audit Trail</div>
<div class="npa-timeline" style="padding: 4px 18px 18px">
@for (h of a.stageHistory; track h.date + h.stage + h.outcome) {
<div [class]="historyToneClass(h.outcome)">
<span class="npa-timeline-item__icon">{{ stepMarker(a, h.stage) }}</span>
<div class="npa-timeline-item__body">
<div class="npa-timeline-item__row">
<span class="npa-timeline-item__type">{{ stageLabels[h.stage] }} — {{ h.outcome }}</span>
<span class="npa-timeline-item__date">{{ h.date }}</span>
</div>
<p class="npa-timeline-item__remarks">{{ h.remarks }}</p>
<span class="npa-timeline-item__channel">{{ h.actionBy }}</span>
</div>
</div>
} @empty {
<p class="pg-empty">No activity recorded yet.</p>
}
</div>
</div>
} @else {
<div class="pg-card">
<div class="pos-section-label">Application Not Found</div>
<div class="loan-action-card">
<button type="button" class="pg-btn pg-btn--ghost" (click)="goBack()">‹ Back to Pipeline</button>
</div>
</div>
}
</div>
@@ -0,0 +1,418 @@
/**
* 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, OnDestroy, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { Subscription } from 'rxjs';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import {
ApprovalField,
BRANCHES,
BranchMaster,
DiscrepancyType,
DISCREPANCY_LABELS,
EmiScheduleRow,
LoanApplication,
LOAN_APPLICATIONS,
OriginationStage,
STAGE_LABELS,
STAGE_ORDER,
STATUS_LABELS,
allotShareMembership,
buildEmiSchedule,
calcEligibleAmount,
calculateEmi,
checkCreditScore,
cibilBand,
CIC_BUREAUS,
clearPersonalDiscussionAndProceed,
confirmChequeAch,
decideApproval,
disburseApplication,
flagDiscrepancy,
isRevolvingProduct,
markCustomerSigned,
progressPct,
pushFieldInquiry,
resolveDiscrepancyAndProceed,
sanctioningAuthority,
setMonthlyIncome,
stageBadgeClass,
stageIndex,
proceedToNextStage,
recordVerification,
todayDDMMYYYY,
uploadAgentReport,
uploadCicReport
} from '../loan-origination-data';
/**
* A single application's full sanctioning journey — the stage stepper, the current stage's
* action card (checklist / CIC upload / Field Investigation / Committee-Risk-Legal approval /
* signing / disbursement), and the complete date-wise audit trail. Every action here mutates the
* SAME LoanApplication object the Pipeline list reads, so the two screens can never disagree.
*/
@Component({
selector: 'mifosx-application-detail',
templateUrl: './application-detail.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../../investment/investment-shared.scss',
'../../npa/npa-shared.scss',
'../loan-origination-shared.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule
]
})
export class ApplicationDetailComponent implements OnInit, OnDestroy {
readonly stageOrder = STAGE_ORDER;
readonly stageLabels = STAGE_LABELS;
readonly statusLabels = STATUS_LABELS;
readonly discrepancyLabels = DISCREPANCY_LABELS;
asOnDate = todayDDMMYYYY();
application: LoanApplication | undefined;
/** Shared remarks box for whichever action card is currently on screen. */
actionRemarks = '';
discrepancySelection: DiscrepancyType = 'NONE';
disbursementMode = 'Account Credit';
/** KYC stage's Monthly Income entry field — blank until the officer keys it in. */
monthlyIncomeInput: number | null = null;
currentUserName = 'Branch User';
/** True for a Head Office login, which can open any branch's application — a branch-level
* login is locked to its own branch, the same split every other report in the app uses. */
isHeadOffice = false;
private ownBranchCode: string | null = null;
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly auth = inject(AuthenticationService);
private sub?: Subscription;
ngOnInit(): void {
const creds = this.auth.getCredentials();
this.currentUserName = creds?.username || 'Branch User';
this.isHeadOffice = this.auth.isHeadOfficeUser();
if (!this.isHeadOffice) {
const officeName = creds?.officeName || '';
this.ownBranchCode =
BRANCHES.find((b: BranchMaster) => officeName.toLowerCase().includes(b.name.toLowerCase()))?.code || null;
}
this.sub = this.route.paramMap.subscribe((params) => {
const applicationNo = params.get('applicationNo');
const found = LOAN_APPLICATIONS.find((a) => a.applicationNo === applicationNo);
// A branch-level login can only open applications belonging to its own branch — treated
// exactly like "not found" rather than a distinct "access denied", so it doesn't reveal
// that a differently-branched application exists at all.
this.application = found && !this.isHeadOffice && found.branchCode !== this.ownBranchCode ? undefined : found;
this.actionRemarks = '';
this.discrepancySelection = 'NONE';
this.monthlyIncomeInput = this.application?.monthlyIncome || null;
});
}
ngOnDestroy(): void {
this.sub?.unsubscribe();
}
goBack(): void {
this.router.navigate(['/loan-origination/pipeline']);
}
// -------------------- Stepper --------------------
stepStatusClass(a: LoanApplication, stage: OriginationStage): string {
const idx = stageIndex(stage);
const curIdx = stageIndex(a.currentStage);
if (stage === a.currentStage && a.overallStatus === 'REJECTED') {
return 'loan-step loan-step--rejected';
}
if (stage === a.currentStage && a.overallStatus === 'ON_HOLD') {
return 'loan-step loan-step--hold';
}
if (idx < curIdx || (idx === curIdx && a.overallStatus === 'DISBURSED')) {
return 'loan-step loan-step--done';
}
if (idx === curIdx) {
return 'loan-step loan-step--current';
}
return 'loan-step';
}
stepMarker(a: LoanApplication, stage: OriginationStage): string {
const idx = stageIndex(stage);
const curIdx = stageIndex(a.currentStage);
if (idx < curIdx || (idx === curIdx && a.overallStatus === 'DISBURSED')) {
return '✓';
}
return String(idx + 1);
}
progress(a: LoanApplication): number {
return progressPct(a);
}
/** Colours an audit-trail entry's icon badge by what actually happened — a rejection/hold
* reads as an alert, everything else (approvals, verifications, uploads, signings) reads
* as positive progress. */
historyToneClass(outcome: string): string {
if (/reject|hold/i.test(outcome)) {
return 'npa-timeline-item npa-timeline-item--alert';
}
return 'npa-timeline-item npa-timeline-item--positive';
}
badgeClass(a: LoanApplication): string {
return stageBadgeClass(a);
}
cibilBandOf(score: number): string {
return cibilBand(score);
}
readonly cicBureaus = CIC_BUREAUS;
checkCreditScoreNow(a: LoanApplication): void {
checkCreditScore(a, this.currentUserName);
}
sanctioningAuthorityOf(a: LoanApplication): string {
return sanctioningAuthority(a.requestedAmount);
}
// -------------------- Generic stage-advance actions --------------------
proceed(a: LoanApplication, remarks: string): void {
proceedToNextStage(a, this.currentUserName, remarks);
}
// -------------------- KYC & Documents --------------------
/** Live preview of what Eligible Loan Amount would be for whatever's currently typed into the
* Monthly Income box — before the officer actually commits it via "Assess Income". */
eligiblePreview(a: LoanApplication): number {
return calcEligibleAmount(this.monthlyIncomeInput || 0, a.productCode);
}
setIncomeNow(a: LoanApplication): void {
if (!this.monthlyIncomeInput || this.monthlyIncomeInput <= 0) {
return;
}
setMonthlyIncome(a, this.monthlyIncomeInput, this.currentUserName);
}
allKycDone(a: LoanApplication): boolean {
const base =
a.aadhaarVerified &&
a.panVerified &&
a.aadhaarFingerprintCaptured &&
a.incomeProofSubmitted &&
a.bankStatementSubmitted &&
a.electricityBillSubmitted &&
a.monthlyIncome > 0;
return a.secured ? base && a.propertyDetailsSubmitted && a.taxReceiptSubmitted : base;
}
// -------------------- Field Investigation --------------------
fiComplete(a: LoanApplication): boolean {
const addressOk = a.addressVerification.status === 'VERIFIED';
const officeOk = a.officeVerification.status === 'VERIFIED';
const propertyOk = !a.secured || a.propertyVerification.status === 'VERIFIED';
return addressOk && officeOk && propertyOk;
}
completeFi(a: LoanApplication): void {
const agent = 'Field Verification Team';
const keys: Array<'addressVerification' | 'officeVerification' | 'propertyVerification'> = [
'addressVerification',
'officeVerification',
'propertyVerification'
];
for (const key of keys) {
if (a[key].status !== 'PENDING') {
recordVerification(
a,
key,
a[key].status,
agent,
a[key].remarks === '—' ? `${key} recorded on FI visit.` : a[key].remarks
);
}
}
this.proceed(a, 'Field Investigation completed — Agent Report filed.');
}
// -------------------- Personal Discussion --------------------
clearPersonalDiscussion(a: LoanApplication): void {
clearPersonalDiscussionAndProceed(
a,
this.currentUserName,
this.actionRemarks.trim() || 'No discrepancy found — cleared to proceed to Loan Committee.'
);
this.actionRemarks = '';
}
flagPersonalDiscussion(a: LoanApplication): void {
const notes =
this.actionRemarks.trim() ||
`${this.discrepancyLabels[this.discrepancySelection]} flagged during personal discussion — held pending resolution.`;
flagDiscrepancy(a, this.discrepancySelection, this.currentUserName, notes);
this.actionRemarks = '';
this.discrepancySelection = 'NONE';
}
resolveDiscrepancy(a: LoanApplication): void {
resolveDiscrepancyAndProceed(
a,
this.currentUserName,
this.actionRemarks.trim() || 'Discrepancy resolved on further review — cleared to proceed.'
);
this.actionRemarks = '';
}
// -------------------- Share Membership --------------------
allotMembership(a: LoanApplication): void {
allotShareMembership(a, this.currentUserName);
}
// -------------------- Approvals (Credit Committee / Risk / Legal / Post-Check) --------------------
approveStage(a: LoanApplication, field: ApprovalField, approverRole: string): void {
decideApproval(
a,
field,
'APPROVED',
this.currentUserName,
approverRole,
this.actionRemarks.trim() || 'Reviewed and approved.'
);
this.actionRemarks = '';
}
rejectStage(a: LoanApplication, field: ApprovalField, approverRole: string): void {
decideApproval(
a,
field,
'REJECTED',
this.currentUserName,
approverRole,
this.actionRemarks.trim() || 'Declined on review.'
);
this.actionRemarks = '';
}
exceptionApprove(a: LoanApplication): void {
decideApproval(
a,
'postCheckApproval',
'EXCEPTION_APPROVED',
this.currentUserName,
'Post-Disbursement Check',
this.actionRemarks.trim() || 'Approved on exception — minor gap noted and accepted.'
);
this.actionRemarks = '';
}
legalChecklistDone(a: LoanApplication): boolean {
return a.feesAndAccountOpened && (!a.secured || a.mortgageCreated) && a.legalStampDone && a.fileFilingDone;
}
committeeReportsReady(a: LoanApplication): boolean {
return (
a.cicReportUploaded &&
a.backgroundCheckDone &&
(!a.secured || a.cersaiSearchDone) &&
a.aadhaarVerified &&
a.panVerified &&
a.incomeProofSubmitted &&
this.fiComplete(a) &&
a.managerMeetingDone
);
}
riskChecklistReady(a: LoanApplication): boolean {
return (
a.addressVerification.status === 'VERIFIED' &&
a.incomeProofSubmitted &&
a.previousLoanDeclarationDone &&
(!a.secured || (a.propertySearchReportDone && a.valuationReportConfirmed)) &&
this.fiComplete(a) &&
a.backgroundCheckDone
);
}
postCheckSignaturesDone(a: LoanApplication): boolean {
return (
a.signedByAgent && a.signedByManager && a.signedByCommittee && a.signedByRiskApproval && a.signedByLegalApproval
);
}
// -------------------- CIC Upload --------------------
onCicFileSelected(event: Event, a: LoanApplication): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
uploadCicReport(a, file.name, this.currentUserName);
input.value = '';
}
pushFieldInquiryNow(a: LoanApplication): void {
pushFieldInquiry(a, 'Field Verification Team', this.currentUserName);
}
onAgentReportFileSelected(event: Event, a: LoanApplication): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
uploadAgentReport(a, file.name, this.currentUserName);
input.value = '';
}
// -------------------- Signing / Cheque-ACH / Disbursement --------------------
markSigned(a: LoanApplication): void {
markCustomerSigned(a, a.applicantName);
}
confirmChequeAndProceed(a: LoanApplication): void {
confirmChequeAch(a, this.currentUserName);
}
disburseNow(a: LoanApplication): void {
disburseApplication(a, this.currentUserName, this.disbursementMode);
}
// -------------------- EMI Repayment Schedule (once disbursed) --------------------
isRevolving(a: LoanApplication): boolean {
return isRevolvingProduct(a.productCode);
}
emiAmountFor(a: LoanApplication): number {
return calculateEmi(a.requestedAmount, a.interestRate, a.tenureMonths);
}
emiScheduleFor(a: LoanApplication): EmiScheduleRow[] {
return buildEmiSchedule(a.requestedAmount, a.interestRate, a.tenureMonths, a.disbursementDate);
}
totalInterestFor(a: LoanApplication): number {
return this.emiScheduleFor(a).reduce((s, r) => s + r.interestComponent, 0);
}
totalPayableFor(a: LoanApplication): number {
return a.requestedAmount + this.totalInterestFor(a);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,46 @@
/**
* 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 { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { LoanPipelineComponent } from './loan-pipeline/loan-pipeline.component';
import { ApplicationDetailComponent } from './application-detail/application-detail.component';
const routes: Routes = [
Route.withShell([
{
path: 'loan-origination',
data: { title: 'Loan Origination', breadcrumb: 'Loan Origination' },
children: [
{
path: '',
redirectTo: 'pipeline',
pathMatch: 'full'
},
{
path: 'pipeline',
component: LoanPipelineComponent,
data: { title: 'Loan Origination Pipeline', breadcrumb: 'Pipeline' }
},
{
path: 'application/:applicationNo',
component: ApplicationDetailComponent,
data: { title: 'Loan Application', breadcrumb: 'Application' }
}
]
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class LoanOriginationRoutingModule {}
@@ -0,0 +1,322 @@
/**
* 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/.
*/
// Loan Origination-specific styling layered on top of ../cashier/cashier-shared.scss (pg-*,
// cctr-*) and ../npa/npa-shared.scss (npa-timeline-* — reused as-is for the application's stage
// history/audit trail, since it's the same generic "date-led event log" primitive already built
// for the NPA Recovery Journey popup, not NPA-specific).
// EMI Repayment Schedule can run to 240 rows (a 20-year Housing Loan) — scrolls within a fixed
// height with the header pinned, rather than pushing the rest of the page far down.
.loan-emi-table-scroll {
max-height: 420px;
overflow-y: auto;
thead th {
position: sticky;
top: 0;
z-index: 1;
background: #fff;
}
}
.loan-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
&--progress {
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
&--hold {
background: #fffbeb;
color: #b45309;
border: 1px solid #fde68a;
}
&--rejected {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
&--disbursed {
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
}
}
.pg-btn--success {
background: linear-gradient(135deg, #22c55e, #15803d);
color: #fff;
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.pg-btn--danger {
background: #fff;
border-color: #fecaca;
color: #dc2626;
&:hover {
background: #fef2f2;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.pg-btn--warn {
background: #fff;
border-color: #fde68a;
color: #b45309;
&:hover {
background: #fffbeb;
}
}
// ---------------------------------------------------------------------------
// Stage stepper — the 12-stage sanctioning pipeline, drawn as a connected row of
// dots so an application's progress reads at a glance.
// ---------------------------------------------------------------------------
.loan-stepper {
display: flex;
align-items: flex-start;
overflow-x: auto;
padding: 22px 18px 16px;
}
.loan-step {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
min-width: 90px;
position: relative;
&__connector {
position: absolute;
top: 15px;
left: -50%;
width: 100%;
height: 2px;
background: #e2e8f0;
z-index: 0;
}
&:first-child &__connector {
display: none;
}
&__dot {
position: relative;
z-index: 1;
width: 32px;
height: 32px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
background: #f1f5f9;
color: #94a3b8;
border: 2px solid #e2e8f0;
}
&__label {
margin-top: 8px;
font-size: 10.5px;
text-align: center;
color: #64748b;
font-weight: 600;
line-height: 1.3;
max-width: 92px;
}
&--done {
.loan-step__dot {
background: #15803d;
border-color: #15803d;
color: #fff;
}
.loan-step__connector {
background: #86efac;
}
.loan-step__label {
color: #15803d;
}
}
&--current {
.loan-step__dot {
background: #4338ca;
border-color: #4338ca;
color: #fff;
box-shadow: 0 0 0 4px rgb(67 56 202 / 15%);
}
.loan-step__label {
color: #4338ca;
font-weight: 800;
}
}
&--rejected {
.loan-step__dot {
background: #dc2626;
border-color: #dc2626;
color: #fff;
}
.loan-step__label {
color: #dc2626;
}
}
&--hold {
.loan-step__dot {
background: #d97706;
border-color: #d97706;
color: #fff;
}
.loan-step__label {
color: #d97706;
}
}
}
// ---------------------------------------------------------------------------
// Action card — the current stage's checklist/inputs and Approve/Reject buttons.
// ---------------------------------------------------------------------------
.loan-action-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 16px 18px;
margin: 0 16px 16px;
}
.loan-checklist {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 14px;
}
.loan-checklist-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: #1e293b;
// stylelint-disable-next-line selector-no-qualifying-type
input[type='checkbox'] {
width: 16px;
height: 16px;
accent-color: #4338ca;
}
}
.loan-action-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 12px;
}
.loan-kv-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px 20px;
padding: 18px;
}
.loan-kv-field {
display: flex;
flex-direction: column;
gap: 3px;
dt {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #64748b;
}
dd {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #0d1b2a;
font-variant-numeric: tabular-nums;
}
}
.loan-approval-summary {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
padding: 4px 18px 18px;
}
.loan-approval-tile {
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 4px;
&__role {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
}
&__decision {
font-size: 13px;
font-weight: 700;
}
&--approved .loan-approval-tile__decision {
color: #15803d;
}
&--rejected .loan-approval-tile__decision {
color: #dc2626;
}
&--pending .loan-approval-tile__decision {
color: #94a3b8;
}
}
@@ -0,0 +1,24 @@
/**
* 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 { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { LoanOriginationRoutingModule } from './loan-origination-routing.module';
import { LoanPipelineComponent } from './loan-pipeline/loan-pipeline.component';
import { ApplicationDetailComponent } from './application-detail/application-detail.component';
@NgModule({
imports: [
SharedModule,
LoanOriginationRoutingModule,
LoanPipelineComponent,
ApplicationDetailComponent
]
})
export class LoanOriginationModule {}
@@ -0,0 +1,570 @@
<!--
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="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">CREDIT · LOAN ORIGINATION · {{ scopeName | uppercase }}</div>
<h1 class="pg-title">Loan Origination Pipeline</h1>
<p class="pg-sub npa-report-note">
Every sanctioning stage from application intake to disbursement — KYC/OVD, CIC credit check, Field
Investigation, Personal Discussion, Credit Committee, Risk &amp; Legal Approval — as on {{ asOnDate }}.
</p>
</div>
<div class="pg-toolbar">
@if (isHeadOffice) {
<label class="pg-field">
<span>Branch</span>
<select class="pg-select" [ngModel]="branchFilter" (ngModelChange)="setBranch($event)">
<option value="">-- All Branches --</option>
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
}
</select>
</label>
} @else {
<label class="pg-field">
<span>Branch</span>
<span class="pg-chip">{{ scopeName }}</span>
</label>
}
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
</div>
</header>
<div class="inv-kpis npa-kpis--ratio">
<div class="inv-kpi">
<span class="inv-kpi__label">Total Applications</span>
<span class="inv-kpi__value">{{ totalApplications }}</span>
<span class="inv-kpi__sub">in the active pipeline</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">In Progress</span>
<span class="inv-kpi__value">{{ inProgressCount }}</span>
<span class="inv-kpi__sub">moving through the workflow</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">On Hold</span>
<span class="inv-kpi__value npa-warn">{{ onHoldCount }}</span>
<span class="inv-kpi__sub">discrepancy under resolution</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Rejected</span>
<span class="inv-kpi__value npa-warn">{{ rejectedCount }}</span>
<span class="inv-kpi__sub">declined at Committee/Risk</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Disbursed</span>
<span class="inv-kpi__value">{{ disbursedApplications.length }}</span>
<span class="inv-kpi__sub">₹{{ disbursedAmount | number: '1.0-0' }} total</span>
</div>
</div>
<div class="cctr-tabs">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeView === 'pipeline'"
(click)="setView('pipeline')"
>
Pipeline
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeView === 'new'"
(click)="setView('new')"
>
New Application
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeView === 'channel'"
(click)="setView('channel')"
>
Channel-wise Report
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeView === 'bc'"
(click)="setView('bc')"
>
BC-wise Report
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeView === 'share'"
(click)="setView('share')"
>
Share Member Report
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeView === 'target'"
(click)="setView('target')"
>
Target vs Actual
</button>
</div>
<!-- ===================== PIPELINE ===================== -->
@if (activeView === 'pipeline') {
<div class="pg-card">
<div class="pos-section-label">Filters</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Channel</span>
<select [(ngModel)]="channelFilter" (ngModelChange)="currentPage = 1">
<option value="">-- All --</option>
<option value="BRANCH">Branch</option>
<option value="BC">Business Correspondent (BC)</option>
<option value="SELF_APPLY">Self-Apply (Digital)</option>
</select>
</label>
<label class="inv-form-field">
<span>Stage</span>
<select [(ngModel)]="stageFilter" (ngModelChange)="currentPage = 1">
<option value="">-- All --</option>
@for (s of stageOrder; track s) {
<option [value]="s">{{ stageLabels[s] }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Status</span>
<select [(ngModel)]="statusFilter" (ngModelChange)="currentPage = 1">
<option value="">-- All --</option>
<option value="IN_PROGRESS">In Progress</option>
<option value="ON_HOLD">On Hold</option>
<option value="REJECTED">Rejected</option>
<option value="DISBURSED">Disbursed</option>
</select>
</label>
<label class="inv-form-field">
<span>Search (Application No / Applicant)</span>
<input
type="text"
[(ngModel)]="searchText"
(ngModelChange)="currentPage = 1"
placeholder="e.g. Ramesh, LN0102..."
/>
</label>
</div>
</div>
<div class="pg-card">
<div class="pos-section-label">Applications ({{ filteredApplications.length }})</div>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>Application No</th>
<th>Applicant</th>
<th>Branch</th>
<th>Channel</th>
<th>Product</th>
<th class="num">Requested Amount</th>
<th>Current Stage</th>
<th class="num">Progress</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (a of paged(filteredApplications); track a.applicationNo) {
<tr class="cctr-branch-row" (click)="openApplication(a)">
<td>
<span class="pg-chip">{{ a.applicationNo }}</span>
</td>
<td>{{ a.applicantName }}</td>
<td>{{ a.branchName }}</td>
<td>{{ channelLabels[a.channel] }}</td>
<td>{{ a.productName }}</td>
<td class="num">₹{{ a.requestedAmount | number: '1.0-0' }}</td>
<td>{{ stageLabel(a.currentStage) }}</td>
<td class="num">{{ progress(a) }}%</td>
<td>
<span [class]="badgeClass(a)">{{ statusLabel(a.overallStatus) }}</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="pg-empty">No applications match these filters.</td>
</tr>
}
</tbody>
</table>
</div>
@if (filteredApplications.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(filteredApplications) }}–{{ rangeEnd(filteredApplications) }} of
{{ filteredApplications.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, filteredApplications)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(filteredApplications) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(filteredApplications)"
(click)="goToPage(currentPage + 1, filteredApplications)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== NEW APPLICATION ===================== -->
@if (activeView === 'new') {
<div class="pg-card">
<div class="pos-section-label">New Application — Channel Intake</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>* Channel</span>
<select [(ngModel)]="newApp.channel">
<option value="BRANCH">Branch</option>
<option value="BC">Business Correspondent (BC)</option>
<option value="SELF_APPLY">Self-Apply (Digital)</option>
</select>
</label>
@if (newApp.channel === 'BC') {
<label class="inv-form-field">
<span>* BC Agent / Point</span>
<input type="text" [(ngModel)]="newApp.bcAgentName" placeholder="e.g. Suvidha BC Point — Andheri" />
</label>
}
<label class="inv-form-field">
<span>* Customer Type</span>
<select [(ngModel)]="newApp.customerType">
<option value="NEW">New Customer</option>
<option value="EXISTING">Existing Customer</option>
</select>
</label>
<label class="inv-form-field">
<span>* Applicant Name</span>
<input type="text" [(ngModel)]="newApp.applicantName" placeholder="Full name" />
</label>
<label class="inv-form-field">
<span>* Branch</span>
<select [(ngModel)]="newApp.branchCode" [disabled]="!isHeadOffice">
@for (b of newApplicationBranches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>* Product</span>
<select [(ngModel)]="newApp.productCode">
@for (p of products; track p.code) {
<option [value]="p.code">{{ p.name }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>* Requested Amount</span>
<input type="number" [(ngModel)]="newApp.requestedAmount" placeholder="₹" />
</label>
<label class="inv-form-field">
<span>Tenure (Months)</span>
<input type="number" [(ngModel)]="newApp.tenureMonths" placeholder="e.g. 36" />
</label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="saveNewApplication()">Submit Application</button>
</div>
</div>
}
<!-- ===================== CHANNEL-WISE REPORT ===================== -->
@if (activeView === 'channel') {
<div class="pg-card">
<div class="pos-section-label">Channel-wise Report — Branch / BC / Self-Apply</div>
<table class="pg-table">
<thead>
<tr>
<th>Channel</th>
<th class="num">Applications</th>
<th class="num">Requested Amount</th>
<th class="num col-in">Disbursed Count</th>
<th class="num col-in">Disbursed Amount</th>
</tr>
</thead>
<tbody>
@for (r of channelReport; track r.channel) {
<tr>
<td>{{ r.label }}</td>
<td class="num">{{ r.count }}</td>
<td class="num">₹{{ r.requestedAmount | number: '1.0-0' }}</td>
<td class="num col-in">{{ r.disbursedCount }}</td>
<td class="num col-in">₹{{ r.disbursedAmount | number: '1.0-0' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td>Total</td>
<td class="num">{{ totalApplications }}</td>
<td class="num">
₹{{
channelReport[0].requestedAmount + channelReport[1].requestedAmount + channelReport[2].requestedAmount
| number: '1.0-0'
}}
</td>
<td class="num">{{ disbursedApplications.length }}</td>
<td class="num">₹{{ disbursedAmount | number: '1.0-0' }}</td>
</tr>
</tfoot>
</table>
</div>
}
<!-- ===================== BC-WISE REPORT ===================== -->
@if (activeView === 'bc') {
<div class="pg-card">
<div class="pos-section-label">
BC-wise Report — Business Correspondent Sourced Applications ({{ bcApplications.length }})
</div>
<table class="pg-table">
<thead>
<tr>
<th>BC Agent / Point</th>
<th class="num">Applications</th>
<th class="num">Requested Amount</th>
<th class="num col-in">Disbursed Count</th>
<th class="num col-in">Disbursed Amount</th>
</tr>
</thead>
<tbody>
@for (r of bcReport; track r.bcAgentName) {
<tr>
<td>{{ r.bcAgentName }}</td>
<td class="num">{{ r.count }}</td>
<td class="num">₹{{ r.requestedAmount | number: '1.0-0' }}</td>
<td class="num col-in">{{ r.disbursedCount }}</td>
<td class="num col-in">₹{{ r.disbursedAmount | number: '1.0-0' }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="pg-empty">No BC-sourced applications in this branch.</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- ===================== SHARE MEMBER REPORT ===================== -->
@if (activeView === 'share') {
<div class="pg-card">
<div class="pos-section-label">Share Member Report</div>
<p class="pg-sub npa-report-note">
A cooperative-bank borrower must hold membership shares in the society before a loan can be sanctioned.
{{ shareMemberCount }} of {{ totalApplications }} applicants are already members; the rest need membership
allotted before Credit Committee approval — done from the application's own detail screen.
</p>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>Application No</th>
<th>Applicant</th>
<th>Branch</th>
<th>Current Stage</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (a of paged(nonMemberApplications); track a.applicationNo) {
<tr class="cctr-branch-row" (click)="openApplication(a)">
<td>
<span class="pg-chip">{{ a.applicationNo }}</span>
</td>
<td>{{ a.applicantName }}</td>
<td>{{ a.branchName }}</td>
<td>{{ stageLabel(a.currentStage) }}</td>
<td>
<span [class]="badgeClass(a)">{{ statusLabel(a.overallStatus) }}</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="pg-empty">Every active applicant in this branch already holds membership.</td>
</tr>
}
</tbody>
</table>
</div>
@if (nonMemberApplications.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(nonMemberApplications) }}–{{ rangeEnd(nonMemberApplications) }} of
{{ nonMemberApplications.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, nonMemberApplications)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(nonMemberApplications) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(nonMemberApplications)"
(click)="goToPage(currentPage + 1, nonMemberApplications)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== TARGET VS ACTUAL ===================== -->
@if (activeView === 'target') {
<div class="pg-card">
<div class="pos-section-label">Branch-wise Disbursement Target — Annual</div>
<table class="pg-table">
<thead>
<tr>
<th>Branch</th>
<th class="num">Target</th>
<th class="num col-in">Disbursed (Actual)</th>
<th class="num">Achievement %</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (row of branchTargetRows; track row.branchCode) {
<tr>
<td>{{ row.branchName }}</td>
<td class="num">₹{{ row.target | number: '1.0-0' }}</td>
<td class="num col-in">₹{{ row.actual | number: '1.0-0' }}</td>
<td class="num">{{ row.achievementPct | number: '1.1-1' }}%</td>
<td>
<span
class="loan-badge"
[class.loan-badge--disbursed]="row.status !== 'BEHIND'"
[class.loan-badge--hold]="row.status === 'BEHIND'"
>
{{ statusLabelOf(row.status) }}
</span>
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="pg-card">
<div class="pos-section-label">Product Mix — Target % vs Actual % (of Disbursed Amount)</div>
<p class="pg-sub npa-report-note">
KCC and Cash Credit (MSME) targets are RBI's actual Priority Sector Lending sub-targets — Agriculture 18% and
Micro Enterprises 7.5% of Adjusted Net Bank Credit (shown here against this module's own mix, since RBI sets
them against the bank's whole loan book, not one product line). The rest are internal business-plan targets, not
RBI-mandated.
</p>
<table class="pg-table">
<thead>
<tr>
<th>Product</th>
<th>Target Basis</th>
<th class="num">Target %</th>
<th class="num col-in">Actual %</th>
<th class="num">Variance</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (row of productTargetRows; track row.code) {
<tr>
<td>{{ row.label }}</td>
<td>
<span class="loan-badge" [class.loan-badge--progress]="row.isRbiMandated">
{{ row.isRbiMandated ? 'RBI PSL Sub-Target' : 'Internal Target' }}
</span>
</td>
<td class="num">{{ row.targetPct | number: '1.0-1' }}%</td>
<td class="num col-in">{{ row.actualPct | number: '1.1-1' }}%</td>
<td class="num" [class.col-out]="row.variancePct < 0" [class.col-in]="row.variancePct > 0">
{{ row.variancePct > 0 ? '+' : '' }}{{ row.variancePct | number: '1.1-1' }} pp
</td>
<td>
<span
class="loan-badge"
[class.loan-badge--disbursed]="row.status !== 'BEHIND'"
[class.loan-badge--hold]="row.status === 'BEHIND'"
>
{{ statusLabelOf(row.status) }}
</span>
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="pg-card">
<div class="pos-section-label">Channel Mix — Target % vs Actual % (of Disbursed Amount)</div>
<table class="pg-table">
<thead>
<tr>
<th>Channel</th>
<th class="num">Target %</th>
<th class="num col-in">Actual %</th>
<th class="num">Variance</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (row of channelTargetRows; track row.code) {
<tr>
<td>{{ row.label }}</td>
<td class="num">{{ row.targetPct | number: '1.0-1' }}%</td>
<td class="num col-in">{{ row.actualPct | number: '1.1-1' }}%</td>
<td class="num" [class.col-out]="row.variancePct < 0" [class.col-in]="row.variancePct > 0">
{{ row.variancePct > 0 ? '+' : '' }}{{ row.variancePct | number: '1.1-1' }} pp
</td>
<td>
<span
class="loan-badge"
[class.loan-badge--disbursed]="row.status !== 'BEHIND'"
[class.loan-badge--hold]="row.status === 'BEHIND'"
>
{{ statusLabelOf(row.status) }}
</span>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
@@ -0,0 +1,684 @@
/**
* 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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ExportSheetSpec, exportWorkbook } from '../../cashier/cashier-export';
import {
BRANCHES,
BranchMaster,
branchTargetAmount,
Channel,
CHANNEL_LABELS,
CHANNEL_TARGET_PCT,
CustomerType,
LoanApplication,
LOAN_APPLICATIONS,
ORIGINATION_PRODUCTS,
OriginationStage,
OverallStatus,
PRODUCT_TARGET_PCT,
STAGE_LABELS,
STAGE_ORDER,
STATUS_LABELS,
TARGET_STATUS_LABELS,
TargetStatus,
createApplication,
progressPct,
stageBadgeClass,
targetStatus,
todayDDMMYYYY
} from '../loan-origination-data';
interface ChannelReportRow {
channel: Channel;
label: string;
count: number;
requestedAmount: number;
disbursedCount: number;
disbursedAmount: number;
}
interface BcReportRow {
bcAgentName: string;
count: number;
requestedAmount: number;
disbursedCount: number;
disbursedAmount: number;
}
interface BranchTargetRow {
branchCode: string;
branchName: string;
target: number;
actual: number;
achievementPct: number;
status: TargetStatus;
}
interface MixTargetRow {
code: string;
label: string;
targetPct: number;
actualPct: number;
variancePct: number;
status: TargetStatus;
/** True only for the two rows whose target is an actual RBI PSL sub-target (Agriculture 18% /
* Micro Enterprises 7.5% of ANBC) rather than an internal business-plan assumption. */
isRbiMandated: boolean;
}
type ViewId = 'pipeline' | 'new' | 'channel' | 'bc' | 'share' | 'target';
/**
* Loan Origination pipeline — every application currently moving through the sanctioning
* workflow (Application → KYC → CIC Check → Field Investigation → Personal Discussion → Credit
* Committee → Risk Approval → Legal Approval → Customer Signing → Cheque/ACH → Post-Check →
* Disbursed), plus the Channel-wise / BC-wise / Share Member reports and a New Application
* intake form. Every count/total here is derived from the same LOAN_APPLICATIONS array the
* Application Detail screen mutates, so the pipeline view can never drift from an application's
* real state.
*/
@Component({
selector: 'mifosx-loan-pipeline',
templateUrl: './loan-pipeline.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../../investment/investment-shared.scss',
'../../npa/npa-shared.scss',
'../loan-origination-shared.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule
]
})
export class LoanPipelineComponent implements OnInit {
readonly stageOrder = STAGE_ORDER;
readonly stageLabels = STAGE_LABELS;
readonly statusLabels = STATUS_LABELS;
readonly channelLabels = CHANNEL_LABELS;
readonly branches = BRANCHES;
readonly products = ORIGINATION_PRODUCTS;
asOnDate = todayDDMMYYYY();
activeView: ViewId = 'pipeline';
isHeadOffice = false;
currentOfficeName = 'Branch Office';
private ownBranchCode: string | null = null;
branchFilter = '';
channelFilter = '';
stageFilter = '';
statusFilter = '';
searchText = '';
readonly pageSize = 20;
currentPage = 1;
generating = false;
newApp = {
channel: 'BRANCH' as Channel,
bcAgentName: '',
customerType: 'NEW' as CustomerType,
applicantName: '',
branchCode: this.branches[0].code,
productCode: this.products[0].code,
requestedAmount: null as number | null,
tenureMonths: null as number | null
};
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly auth = inject(AuthenticationService);
ngOnInit(): void {
const requestedView = this.route.snapshot.queryParamMap.get('view') as ViewId | null;
if (requestedView && [
'pipeline',
'new',
'channel',
'bc',
'share',
'target'
].includes(requestedView)) {
this.activeView = requestedView;
}
const creds = this.auth.getCredentials();
this.isHeadOffice = this.auth.isHeadOfficeUser();
this.currentOfficeName = creds?.officeName || 'Branch Office';
if (this.isHeadOffice) {
this.branchFilter = '';
} else {
this.ownBranchCode =
this.branches.find((b: BranchMaster) => this.currentOfficeName.toLowerCase().includes(b.name.toLowerCase()))
?.code || null;
this.branchFilter = this.ownBranchCode || this.branches[0].code;
// A branch-level login can only originate applications for its own branch — locked here,
// not just hidden, since the New Application form posts whatever branchCode is bound.
this.newApp.branchCode = this.branchFilter;
}
}
/** Branches selectable on the New Application form — every branch for Head Office, only the
* logged-in user's own branch otherwise. */
get newApplicationBranches(): BranchMaster[] {
return this.isHeadOffice ? this.branches : this.branches.filter((b) => b.code === this.branchFilter);
}
setView(view: ViewId): void {
this.activeView = view;
this.currentPage = 1;
}
setBranch(code: string): void {
this.branchFilter = code;
this.currentPage = 1;
}
/** Name of the branch/scope currently being viewed, for display in the header. */
get scopeName(): string {
if (!this.isHeadOffice) {
return this.currentOfficeName;
}
return this.branchFilter
? this.branches.find((b) => b.code === this.branchFilter)?.name || ''
: 'All Branches (Consolidated)';
}
get scopedApplications(): LoanApplication[] {
return this.branchFilter ? LOAN_APPLICATIONS.filter((a) => a.branchCode === this.branchFilter) : LOAN_APPLICATIONS;
}
get filteredApplications(): LoanApplication[] {
return this.scopedApplications.filter((a) => {
if (this.channelFilter && a.channel !== this.channelFilter) {
return false;
}
if (this.stageFilter && a.currentStage !== this.stageFilter) {
return false;
}
if (this.statusFilter && a.overallStatus !== this.statusFilter) {
return false;
}
if (this.searchText) {
const q = this.searchText.toLowerCase();
return a.applicationNo.toLowerCase().includes(q) || a.applicantName.toLowerCase().includes(q);
}
return true;
});
}
// -------------------- Pagination --------------------
paged<T>(items: T[]): T[] {
const start = (this.currentPage - 1) * this.pageSize;
return items.slice(start, start + this.pageSize);
}
pageCount(items: unknown[]): number {
return Math.max(1, Math.ceil(items.length / this.pageSize));
}
rangeStart(items: unknown[]): number {
return items.length === 0 ? 0 : (this.currentPage - 1) * this.pageSize + 1;
}
rangeEnd(items: unknown[]): number {
return Math.min(this.currentPage * this.pageSize, items.length);
}
goToPage(page: number, items: unknown[]): void {
this.currentPage = Math.min(Math.max(1, page), this.pageCount(items));
}
// -------------------- Portfolio KPIs --------------------
get totalApplications(): number {
return this.scopedApplications.length;
}
get inProgressCount(): number {
return this.scopedApplications.filter((a) => a.overallStatus === 'IN_PROGRESS').length;
}
get onHoldCount(): number {
return this.scopedApplications.filter((a) => a.overallStatus === 'ON_HOLD').length;
}
get rejectedCount(): number {
return this.scopedApplications.filter((a) => a.overallStatus === 'REJECTED').length;
}
get disbursedApplications(): LoanApplication[] {
return this.scopedApplications.filter((a) => a.overallStatus === 'DISBURSED');
}
get disbursedAmount(): number {
return this.disbursedApplications.reduce((s, a) => s + a.requestedAmount, 0);
}
progress(a: LoanApplication): number {
return progressPct(a);
}
badgeClass(a: LoanApplication): string {
return stageBadgeClass(a);
}
statusLabel(status: OverallStatus): string {
return STATUS_LABELS[status];
}
stageLabel(stage: OriginationStage): string {
return STAGE_LABELS[stage];
}
openApplication(a: LoanApplication): void {
this.router.navigate([
'/loan-origination/application',
a.applicationNo
]);
}
// -------------------- Channel-wise / BC-wise / Share Member reports --------------------
get channelReport(): ChannelReportRow[] {
const channels: Channel[] = [
'BRANCH',
'BC',
'SELF_APPLY'
];
return channels.map((channel) => {
const rows = this.scopedApplications.filter((a) => a.channel === channel);
const disbursed = rows.filter((a) => a.overallStatus === 'DISBURSED');
return {
channel,
label: CHANNEL_LABELS[channel],
count: rows.length,
requestedAmount: rows.reduce((s, a) => s + a.requestedAmount, 0),
disbursedCount: disbursed.length,
disbursedAmount: disbursed.reduce((s, a) => s + a.requestedAmount, 0)
};
});
}
get bcApplications(): LoanApplication[] {
return this.scopedApplications.filter((a) => a.channel === 'BC');
}
get bcReport(): BcReportRow[] {
const names = Array.from(new Set(this.bcApplications.map((a) => a.bcAgentName))).sort();
return names.map((bcAgentName) => {
const rows = this.bcApplications.filter((a) => a.bcAgentName === bcAgentName);
const disbursed = rows.filter((a) => a.overallStatus === 'DISBURSED');
return {
bcAgentName,
count: rows.length,
requestedAmount: rows.reduce((s, a) => s + a.requestedAmount, 0),
disbursedCount: disbursed.length,
disbursedAmount: disbursed.reduce((s, a) => s + a.requestedAmount, 0)
};
});
}
get shareMemberCount(): number {
return this.scopedApplications.filter((a) => a.isShareMember).length;
}
get nonMemberApplications(): LoanApplication[] {
return this.scopedApplications.filter((a) => !a.isShareMember && a.overallStatus !== 'REJECTED');
}
// -------------------- Disbursement Target vs Actual --------------------
statusLabelOf(status: TargetStatus): string {
return TARGET_STATUS_LABELS[status];
}
/** Branches shown in the target table: every branch for Head Office's consolidated view, only
* the drilled-into/own branch otherwise — same scoping the rest of the screen already uses. */
get branchTargetRows(): BranchTargetRow[] {
const branchesToShow =
this.isHeadOffice && !this.branchFilter
? this.branches
: this.branches.filter((b) => b.code === this.branchFilter);
return branchesToShow.map((b) => {
const actual = LOAN_APPLICATIONS.filter((a) => a.branchCode === b.code && a.overallStatus === 'DISBURSED').reduce(
(s, a) => s + a.requestedAmount,
0
);
const target = branchTargetAmount(b.code);
const achievementPct = target ? (actual / target) * 100 : 0;
return {
branchCode: b.code,
branchName: b.name,
target,
actual,
achievementPct,
status: targetStatus(achievementPct, 100, 5)
};
});
}
private get disbursedInScope(): LoanApplication[] {
return this.scopedApplications.filter((a) => a.overallStatus === 'DISBURSED');
}
get productTargetRows(): MixTargetRow[] {
const total = this.disbursedInScope.reduce((s, a) => s + a.requestedAmount, 0);
return this.products.map((p) => {
const productAmount = this.disbursedInScope
.filter((a) => a.productCode === p.code)
.reduce((s, a) => s + a.requestedAmount, 0);
const actualPct = total ? (productAmount / total) * 100 : 0;
const targetPct = PRODUCT_TARGET_PCT[p.code] || 0;
return {
code: p.code,
label: p.name,
targetPct,
actualPct,
variancePct: actualPct - targetPct,
status: targetStatus(actualPct, targetPct, 3),
isRbiMandated: p.code === 'KCC' || p.code === 'CC'
};
});
}
get channelTargetRows(): MixTargetRow[] {
const total = this.disbursedInScope.reduce((s, a) => s + a.requestedAmount, 0);
const channels: Channel[] = [
'BRANCH',
'BC',
'SELF_APPLY'
];
return channels.map((channel) => {
const channelAmount = this.disbursedInScope
.filter((a) => a.channel === channel)
.reduce((s, a) => s + a.requestedAmount, 0);
const actualPct = total ? (channelAmount / total) * 100 : 0;
const targetPct = CHANNEL_TARGET_PCT[channel];
return {
code: channel,
label: CHANNEL_LABELS[channel],
targetPct,
actualPct,
variancePct: actualPct - targetPct,
status: targetStatus(actualPct, targetPct, 3),
isRbiMandated: false
};
});
}
// -------------------- New Application intake --------------------
productOf(code: string) {
return this.products.find((p) => p.code === code) || this.products[0];
}
saveNewApplication(): void {
if (!this.newApp.applicantName.trim() || !this.newApp.requestedAmount || this.newApp.requestedAmount <= 0) {
this.toast('Enter the applicant name and a valid requested amount.');
return;
}
if (this.newApp.channel === 'BC' && !this.newApp.bcAgentName.trim()) {
this.toast('Enter the BC agent/point name for a Business Correspondent application.');
return;
}
// A branch-level login is locked to its own branch regardless of what the (disabled) select
// is bound to — defence in depth against the DOM value ever being tampered with.
if (!this.isHeadOffice && this.ownBranchCode) {
this.newApp.branchCode = this.ownBranchCode;
}
const branch = this.branches.find((b) => b.code === this.newApp.branchCode) || this.branches[0];
const product = this.productOf(this.newApp.productCode);
const seq = LOAN_APPLICATIONS.length + 20000;
const app = createApplication(
{
channel: this.newApp.channel,
bcAgentName: this.newApp.bcAgentName || '—',
customerType: this.newApp.customerType,
applicantName: this.newApp.applicantName.trim(),
branchCode: branch.code,
branchName: branch.name,
productCode: product.code,
productName: product.name,
secured: product.secured,
requestedAmount: this.newApp.requestedAmount,
tenureMonths: this.newApp.tenureMonths || product.minTenure,
interestRate: +((product.minRate + product.maxRate) / 2).toFixed(2)
},
seq
);
LOAN_APPLICATIONS.unshift(app);
this.toast(`Application ${app.applicationNo} received for ${app.applicantName}.`);
this.newApp = {
channel: 'BRANCH',
bcAgentName: '',
customerType: 'NEW',
applicantName: '',
branchCode: this.isHeadOffice ? this.branches[0].code : this.ownBranchCode || this.branches[0].code,
productCode: this.products[0].code,
requestedAmount: null,
tenureMonths: null
};
this.router.navigate([
'/loan-origination/application',
app.applicationNo
]);
}
// -------------------- Export to Excel --------------------
async exportExcel(): Promise<void> {
this.generating = true;
try {
const subtitle = `Scope: ${this.scopeName} · As On: ${this.asOnDate}`;
const sheets: ExportSheetSpec[] = [
{
sheetName: 'Pipeline',
title: 'Loan Origination Pipeline',
subtitle,
headers: [
'Application No',
'Applicant',
'Branch',
'Channel',
'Product',
'Requested Amount',
'Current Stage',
'Status'
],
widths: [
16,
22,
16,
22,
16,
18,
24,
14
],
rows: this.filteredApplications.map((a) => [
a.applicationNo,
a.applicantName,
a.branchName,
CHANNEL_LABELS[a.channel],
a.productName,
a.requestedAmount,
STAGE_LABELS[a.currentStage],
STATUS_LABELS[a.overallStatus]
])
},
{
sheetName: 'Channel-wise',
title: 'Channel-wise Report',
subtitle,
headers: [
'Channel',
'Applications',
'Requested Amount',
'Disbursed Count',
'Disbursed Amount'
],
widths: [
26,
16,
18,
16,
18
],
rows: this.channelReport.map((r) => [
r.label,
r.count,
r.requestedAmount,
r.disbursedCount,
r.disbursedAmount
])
},
{
sheetName: 'BC-wise',
title: 'BC-wise Report',
subtitle,
headers: [
'BC Agent / Point',
'Applications',
'Requested Amount',
'Disbursed Count',
'Disbursed Amount'
],
widths: [
30,
16,
18,
16,
18
],
rows: this.bcReport.map((r) => [
r.bcAgentName,
r.count,
r.requestedAmount,
r.disbursedCount,
r.disbursedAmount
])
},
{
sheetName: 'Share Member',
title: 'Share Member Report',
subtitle,
headers: [
'Application No',
'Applicant',
'Branch',
'Current Stage',
'Status'
],
widths: [
16,
22,
16,
24,
14
],
rows: this.nonMemberApplications.map((a) => [
a.applicationNo,
a.applicantName,
a.branchName,
STAGE_LABELS[a.currentStage],
STATUS_LABELS[a.overallStatus]
])
},
{
sheetName: 'Branch Target',
title: 'Branch-wise Disbursement Target — Annual',
subtitle,
headers: [
'Branch',
'Target',
'Disbursed (Actual)',
'Achievement %',
'Status'
],
widths: [
20,
18,
18,
14,
16
],
rows: this.branchTargetRows.map((r) => [
r.branchName,
r.target,
r.actual,
Number(r.achievementPct.toFixed(1)),
this.statusLabelOf(r.status)
])
},
{
sheetName: 'Product-Channel Target',
title: 'Product & Channel Mix — Target % vs Actual %',
subtitle,
headers: [
'Mix',
'Target Basis',
'Target %',
'Actual %',
'Variance (pp)',
'Status'
],
widths: [
26,
18,
12,
12,
14,
16
],
rows: [
...this.productTargetRows.map((r) => [
`Product — ${r.label}`,
r.isRbiMandated ? 'RBI PSL Sub-Target' : 'Internal Target',
r.targetPct,
Number(r.actualPct.toFixed(1)),
Number(r.variancePct.toFixed(1)),
this.statusLabelOf(r.status)
]),
...this.channelTargetRows.map((r) => [
`Channel — ${r.label}`,
'Internal Target',
r.targetPct,
Number(r.actualPct.toFixed(1)),
Number(r.variancePct.toFixed(1)),
this.statusLabelOf(r.status)
])
]
}
];
await exportWorkbook(sheets, `Loan-Origination-${this.scopeName}-${this.asOnDate}`);
} catch (err) {
console.error('Loan Origination export failed', err);
} finally {
this.generating = false;
}
}
private toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
if (this.toastTimer) {
clearTimeout(this.toastTimer);
}
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
}
}
+981
View File
@@ -0,0 +1,981 @@
/**
* 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/.
*/
// Dummy NPA (Non-Performing Asset) data layer for the NPA Reports / Analysis / Classification
// screens. Everything downstream — classification, provisioning, interest reversal, GNPA/NNPA —
// is *derived* from LOAN_ACCOUNTS via pure functions below, so no two reports can ever disagree
// on the same account's numbers.
//
// The book is generated with a seeded RNG (not Math.random()) so the "large, real-world-CBS"
// dataset stays identical across reloads instead of reshuffling every time the page opens.
export type NpaStage =
| 'STANDARD'
| 'SMA0'
| 'SMA1'
| 'SMA2'
| 'SUBSTANDARD'
| 'DOUBTFUL1'
| 'DOUBTFUL2'
| 'DOUBTFUL3'
| 'LOSS';
export const STAGE_ORDER: NpaStage[] = [
'STANDARD',
'SMA0',
'SMA1',
'SMA2',
'SUBSTANDARD',
'DOUBTFUL1',
'DOUBTFUL2',
'DOUBTFUL3',
'LOSS'
];
export const STAGE_LABELS: Record<NpaStage, string> = {
STANDARD: 'Standard',
SMA0: 'SMA-0',
SMA1: 'SMA-1',
SMA2: 'SMA-2',
SUBSTANDARD: 'Sub-Standard',
DOUBTFUL1: 'Doubtful-1',
DOUBTFUL2: 'Doubtful-2',
DOUBTFUL3: 'Doubtful-3',
LOSS: 'Loss Asset'
};
export function isNpaStage(stage: NpaStage): boolean {
return stage !== 'STANDARD' && stage !== 'SMA0' && stage !== 'SMA1' && stage !== 'SMA2';
}
export function isSmaStage(stage: NpaStage): boolean {
return stage === 'SMA0' || stage === 'SMA1' || stage === 'SMA2';
}
export function stageBadgeClass(stage: NpaStage): string {
return `npa-badge npa-badge--${stage.toLowerCase()}`;
}
/** Recovery-workflow stage tracked separately from IRAC classification — a Doubtful/Loss
* account can sit at any of these while its classification stays driven purely by overdueDays. */
export type LegalActionStatus = 'NONE' | 'NOTICE_ISSUED' | 'SARFAESI_INITIATED' | 'SUIT_FILED' | 'OTS_NEGOTIATION';
export const LEGAL_ACTION_LABELS: Record<LegalActionStatus, string> = {
NONE: 'No Action Yet',
NOTICE_ISSUED: 'Demand Notice Issued',
SARFAESI_INITIATED: 'SARFAESI Action Initiated',
SUIT_FILED: 'Recovery Suit Filed',
OTS_NEGOTIATION: 'One-Time Settlement Under Negotiation'
};
export interface RecoveryTxn {
date: string;
amount: number;
mode: string;
}
/** One entry in an account's collection/recovery journey — the real-world touchpoints a CBS
* Recovery Management System logs against an overdue loan, in the order they actually happened. */
export type RecoveryEventType =
| 'SMS Reminder'
| 'IVR / Auto Call'
| 'Tele-calling'
| 'Overdue Call'
| 'Field Visit'
| 'Formal Overdue Letter'
| 'Recovery Agent Assigned'
| 'Demand Notice'
| 'Payment Received';
export interface RecoveryEvent {
date: string;
type: RecoveryEventType;
channel: string;
remarks: string;
}
export interface LoanAccount {
accountNo: string;
customerName: string;
guarantorName: string;
branchCode: string;
branchName: string;
productCode: string;
productName: string;
secured: boolean;
repaymentType: RepaymentType;
sanctionedAmount: number;
disbursementDate: string;
interestRate: number;
outstandingBalance: number;
overdueAmount: number;
overdueDays: number;
overdueDaysQuarterAgo: number;
collateralValue: number;
/** Current estimated market value of the pledged security — can run above or below
* `collateralValue` (the value recorded at sanction/last valuation), which is exactly what the
* Exception Check / Agriculture-Gold Market Value reports exist to catch. 0 for unsecured loans. */
collateralMarketValue: number;
/** Fixed EMI for an EMI-repayment loan (standard reducing-balance formula on the sanctioned
* amount and tenure) — 0 for OVERDRAWN/BULLET loans, which aren't due on a fixed instalment. */
emiAmount: number;
/** Drawing limit for an OVERDRAWN (CC/OD/KCC) account — kept below the sanctioned amount by a
* margin, same as a real cash-credit facility. 0 for EMI/BULLET loans. */
drawingPower: number;
/** Bullet-repayment due date (principal falls due in full on this date) — '—' for EMI/OVERDRAWN
* loans, which don't have a single bullet maturity. */
maturityDate: string;
lastPaymentDate: string;
isLossAsset: boolean;
isRestructured: boolean;
provisionCoverageRatio: number;
recoveries: RecoveryTxn[];
/** Chronological log of collection touchpoints (SMS/call/visit/notice) plus actual recoveries —
* empty for an account that has never gone overdue. */
collectionTimeline: RecoveryEvent[];
/** '—' for Standard/SMA accounts — only NPA accounts are actually assigned a reason. */
npaReason: string;
/** '—' for Standard accounts — SMA and NPA accounts get a recovery officer assigned for follow-up. */
recoveryOfficer: string;
legalActionStatus: LegalActionStatus;
}
// -------------------------------------------------------------------------
// Date helpers
// -------------------------------------------------------------------------
function fmt(d: Date): string {
return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
}
function addDays(d: Date, days: number): Date {
const r = new Date(d);
r.setDate(r.getDate() + days);
return r;
}
/** Parses a "DD-MM-YYYY" string back into a Date, so generated dates can be compared/ordered
* against each other (e.g. "is this recovery more recent than the last-payment estimate?"). */
function parseDMY(s: string): Date {
const [
d,
m,
y
] = s.split('-').map(Number);
return new Date(y, m - 1, d);
}
export function todayDDMMYYYY(): string {
return fmt(new Date());
}
// -------------------------------------------------------------------------
// Seeded RNG — keeps the generated book stable across reloads
// -------------------------------------------------------------------------
function mulberry32(seed: number): () => number {
return function random(): number {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rng = mulberry32(19870614);
const pick = <T>(arr: T[]): T => arr[Math.floor(rng() * arr.length)];
const between = (min: number, max: number): number => min + rng() * (max - min);
const round = (n: number): number => Math.round(n);
// -------------------------------------------------------------------------
// Masters
// -------------------------------------------------------------------------
export interface BranchMaster {
code: string;
name: string;
}
// BR01/BR02 are deliberately named "Branch 1"/"Branch 2" — identical to the two branches in
// cash-clearing-transfer-summary.component.ts — so a single pair of real office names in the DB
// gets recognised by BOTH reports instead of each module expecting a different branch name.
export const BRANCHES: BranchMaster[] = [
{ code: 'BR01', name: 'Branch 1' },
{ code: 'BR02', name: 'Branch 2' },
{ code: 'BR03', name: 'Borivali Branch' },
{ code: 'BR04', name: 'Thane Branch' },
{ code: 'BR05', name: 'Pune Branch' },
{ code: 'BR06', name: 'Nashik Branch' },
{ code: 'BR07', name: 'Nagpur Branch' },
{ code: 'BR08', name: 'Kolhapur Branch' }
];
/** How the loan is contractually repaid — drives the Recovery Management System's per-type
* overdue check (EMI shortfall / drawing-power breach / bullet-maturity lapse) and the
* Expected-vs-Actual Recovery figures, since each type is "due" on a different basis. */
export type RepaymentType = 'EMI' | 'OVERDRAWN' | 'BULLET';
export const REPAYMENT_TYPE_LABELS: Record<RepaymentType, string> = {
EMI: 'Term Loan (EMI)',
OVERDRAWN: 'Overdrawn (CC / OD)',
BULLET: 'Bullet Repayment'
};
interface ProductMaster {
code: string;
name: string;
secured: boolean;
minAmt: number;
maxAmt: number;
minRate: number;
maxRate: number;
repaymentType: RepaymentType;
/** Sanctioned tenure in months — drives the EMI formula for EMI-type products and the
* bullet maturity date for BULLET-type products. Renewal cycle (not a repayment schedule)
* for OVERDRAWN/revolving products. */
tenureMonths: number;
}
export const LOAN_PRODUCTS: ProductMaster[] = [
{
code: '01',
name: 'Cash Credit (MSME)',
secured: true,
minAmt: 500000,
maxAmt: 5000000,
minRate: 11,
maxRate: 13.5,
repaymentType: 'OVERDRAWN',
tenureMonths: 12
},
{
code: '02',
name: 'Term Loan - MSME',
secured: true,
minAmt: 300000,
maxAmt: 3000000,
minRate: 11,
maxRate: 14,
repaymentType: 'EMI',
tenureMonths: 60
},
{
code: '03',
name: 'Housing Loan',
secured: true,
minAmt: 1000000,
maxAmt: 4000000,
minRate: 8.5,
maxRate: 10.5,
repaymentType: 'EMI',
tenureMonths: 180
},
{
code: '04',
name: 'Vehicle Loan',
secured: true,
minAmt: 300000,
maxAmt: 1500000,
minRate: 9,
maxRate: 11.5,
repaymentType: 'EMI',
tenureMonths: 84
},
{
code: '05',
name: 'Gold Loan',
secured: true,
minAmt: 50000,
maxAmt: 1000000,
minRate: 9,
maxRate: 12,
repaymentType: 'BULLET',
tenureMonths: 12
},
{
code: '06',
name: 'Kisan Credit Card',
secured: true,
minAmt: 100000,
maxAmt: 500000,
minRate: 7,
maxRate: 9,
repaymentType: 'OVERDRAWN',
tenureMonths: 12
},
{
code: '07',
name: 'Personal Loan',
secured: false,
minAmt: 50000,
maxAmt: 500000,
minRate: 13,
maxRate: 16,
repaymentType: 'EMI',
tenureMonths: 36
},
{
code: '08',
name: 'Education Loan',
secured: false,
minAmt: 200000,
maxAmt: 1500000,
minRate: 10,
maxRate: 12,
repaymentType: 'EMI',
tenureMonths: 84
},
{
code: '09',
name: 'SHG / Micro Finance Loan',
secured: false,
minAmt: 20000,
maxAmt: 200000,
minRate: 12,
maxRate: 14,
repaymentType: 'EMI',
tenureMonths: 24
},
{
code: '10',
name: 'Overdraft Against Property',
secured: true,
minAmt: 500000,
maxAmt: 2500000,
minRate: 11,
maxRate: 13,
repaymentType: 'OVERDRAWN',
tenureMonths: 12
}
];
const FIRST_NAMES = [
'Ramesh',
'Suresh',
'Mahesh',
'Ganesh',
'Dinesh',
'Vijay',
'Sanjay',
'Ajay',
'Prakash',
'Vinod',
'Sunita',
'Anita',
'Kavita',
'Savita',
'Rekha',
'Meena',
'Pooja',
'Sangita',
'Nirmala',
'Usha',
'Ravindra',
'Mahendra',
'Narendra',
'Jitendra',
'Rajendra',
'Bhagwan',
'Balasaheb',
'Vasant',
'Shivaji',
'Yogesh',
'Kiran',
'Manisha',
'Deepak',
'Sandeep',
'Pradeep',
'Ashok',
'Vishal',
'Nitin',
'Amol',
'Santosh'
];
const LAST_NAMES = [
'Patil',
'More',
'Jadhav',
'Deshmukh',
'Pawar',
'Kulkarni',
'Shinde',
'Gaikwad',
'Chavan',
'Bhosale',
'Kale',
'Salunkhe',
'Sawant',
'Kadam',
'Joshi',
'Wagh',
'Thorat',
'Waghmare',
'Mane',
'Shaikh'
];
const NPA_REASONS = [
'Crop Failure / Agri Distress',
'Business Loss',
'Health Issue / Death of Borrower',
'Diversion of Funds',
'Wilful Default',
'Natural Calamity',
'Loss of Employment',
'Family Dispute / Litigation',
'Delayed Receivables from Debtors',
'Unit Closure'
];
/** Recovery officers are bank staff, kept distinct from the FIRST_NAMES/LAST_NAMES pool used for
* customers so a name never accidentally reads as "the officer is also the borrower". */
const RECOVERY_OFFICERS = [
'Anil Deshpande',
'Sunil Kadam',
'Vikas Pathak',
'Rajendra More',
'Sachin Bhagat',
'Prashant Jagtap',
'Milind Kulkarni',
'Ravi Ghadge'
];
// -------------------------------------------------------------------------
// Weighted stage buckets — used only to seed a realistic distribution of
// overdue-day counts; the actual classification is always re-derived from
// `overdueDays` via classifyDays(), never stored directly.
// -------------------------------------------------------------------------
interface StageBucket {
weight: number;
minDays: number;
maxDays: number;
isLossPool: boolean;
}
const BUCKETS: StageBucket[] = [
{ weight: 60, minDays: 0, maxDays: 0, isLossPool: false },
{ weight: 10, minDays: 1, maxDays: 30, isLossPool: false },
{ weight: 7, minDays: 31, maxDays: 60, isLossPool: false },
{ weight: 6, minDays: 61, maxDays: 90, isLossPool: false },
{ weight: 9, minDays: 91, maxDays: 365, isLossPool: false },
{ weight: 4, minDays: 366, maxDays: 730, isLossPool: false },
{ weight: 2.5, minDays: 731, maxDays: 1095, isLossPool: false },
{ weight: 1.5, minDays: 1096, maxDays: 1900, isLossPool: true }
];
const TOTAL_WEIGHT = BUCKETS.reduce((s, b) => s + b.weight, 0);
function pickBucket(): StageBucket {
let r = rng() * TOTAL_WEIGHT;
for (const b of BUCKETS) {
if (r < b.weight) {
return b;
}
r -= b.weight;
}
return BUCKETS[0];
}
/** Standard reducing-balance EMI formula on the original sanctioned amount and tenure — the same
* fixed instalment the loan was actually sanctioned with, not something recomputed off the
* current (partly-repaid) outstanding balance. */
function calcEmi(principal: number, annualRatePct: number, tenureMonths: number): number {
const r = annualRatePct / 12 / 100;
if (r === 0) {
return round(principal / tenureMonths);
}
const factor = Math.pow(1 + r, tenureMonths);
return round((principal * r * factor) / (factor - 1));
}
interface TimelineCheckpoint {
atDay: number;
type: RecoveryEventType;
channel: string;
remarks: string;
}
/** The real-world escalation ladder a CBS Recovery Management System runs an overdue account
* through — each checkpoint only fires once the account has actually been overdue that long, so
* a 10-day-overdue account shows just the SMS/call steps while a 200-day NPA shows the full
* ladder up to the demand notice. */
const TIMELINE_CHECKPOINTS: TimelineCheckpoint[] = [
{
atDay: 3,
type: 'SMS Reminder',
channel: 'SMS Gateway',
remarks: 'Auto SMS reminder sent for the overdue instalment/interest.'
},
{
atDay: 7,
type: 'IVR / Auto Call',
channel: 'IVR System',
remarks: 'Automated voice-call reminder placed to the registered mobile number.'
},
{
atDay: 15,
type: 'Tele-calling',
channel: 'Branch Tele-calling Desk',
remarks: 'Branch staff called the customer to follow up on the continuing overdue.'
},
{
atDay: 25,
type: 'Overdue Call',
channel: 'Recovery Officer',
remarks: 'Recovery officer placed a personal follow-up call.'
},
{
atDay: 30,
type: 'Field Visit',
channel: 'Branch Staff',
remarks: 'Field visit conducted at the registered address — account under SMA monitoring.'
},
{
atDay: 45,
type: 'SMS Reminder',
channel: 'SMS Gateway',
remarks: 'Second reminder SMS sent — account still overdue.'
},
{
atDay: 60,
type: 'Formal Overdue Letter',
channel: 'Registered Post',
remarks: 'Formal overdue letter dispatched — account crossed into SMA-2 monitoring.'
},
{
atDay: 75,
type: 'Recovery Agent Assigned',
channel: 'External Recovery Agency',
remarks: 'Case assigned to a recovery agent for intensified follow-up.'
},
{
atDay: 91,
type: 'Demand Notice',
channel: 'Recovery Officer',
remarks: 'Account classified NPA — formal demand notice process initiated.'
}
];
/** Builds the account's full collection journey — every escalation checkpoint the account has
* actually reached, interleaved date-wise with its real cash recoveries — so the Account Detail
* screen can show one combined, chronological timeline instead of two disconnected lists. */
function buildCollectionTimeline(overdueDays: number, recoveries: RecoveryTxn[], today: Date): RecoveryEvent[] {
const events: RecoveryEvent[] = [];
for (const cp of TIMELINE_CHECKPOINTS) {
if (cp.atDay <= overdueDays) {
events.push({
date: fmt(addDays(today, -(overdueDays - cp.atDay))),
type: cp.type,
channel: cp.channel,
remarks: cp.remarks
});
}
}
for (const r of recoveries) {
events.push({
date: r.date,
type: 'Payment Received',
channel: r.mode,
remarks: `Recovery of ₹${r.amount.toLocaleString('en-IN')} received via ${r.mode}.`
});
}
events.sort((a, b) => parseDMY(a.date).getTime() - parseDMY(b.date).getTime());
return events;
}
/** Recovery-workflow progression — only NPA accounts get any legal action, and the more
* advanced/stressed the classification, the more likely the bank has already escalated. */
function legalActionFor(stage: NpaStage): LegalActionStatus {
if (!isNpaStage(stage)) {
return 'NONE';
}
const r = rng();
if (stage === 'SUBSTANDARD') {
return r < 0.2 ? 'NOTICE_ISSUED' : 'NONE';
}
if (stage === 'DOUBTFUL1') {
if (r < 0.15) return 'OTS_NEGOTIATION';
if (r < 0.45) return 'SARFAESI_INITIATED';
if (r < 0.65) return 'NOTICE_ISSUED';
return 'NONE';
}
// DOUBTFUL2, DOUBTFUL3, LOSS
if (r < 0.1) return 'OTS_NEGOTIATION';
if (r < 0.55) return 'SUIT_FILED';
if (r < 0.8) return 'SARFAESI_INITIATED';
if (r < 0.9) return 'NOTICE_ISSUED';
return 'NONE';
}
function generateAccounts(count: number): LoanAccount[] {
const today = new Date();
const accounts: LoanAccount[] = [];
for (let i = 0; i < count; i++) {
const branch = pick(BRANCHES);
const product = pick(LOAN_PRODUCTS);
const bucket = pickBucket();
const overdueDays = bucket.maxDays === 0 ? 0 : round(between(bucket.minDays, bucket.maxDays));
const isLossAsset = bucket.isLossPool && rng() < 0.4;
// Restructured accounts skew towards the already-stressed buckets (SMA-2 and worse),
// matching how RBI restructuring relief is actually used in practice.
const stressed = classifyDays(overdueDays, isLossAsset);
const isRestructured =
rng() < (stressed === 'STANDARD' || stressed === 'SMA0' || stressed === 'SMA1' ? 0.04 : 0.18);
const sanctionedAmount = round(between(product.minAmt, product.maxAmt) / 1000) * 1000;
// The loan must be old enough to have actually accrued `overdueDays` of delinquency, plus a
// minimum "good" performing stretch beforehand — otherwise a badly-overdue account could end
// up with a disbursement date that falls AFTER its own last-payment/NPA date, which would look
// wrong no matter what day this report is viewed on.
const minGoodPeriodDays = round(between(30, 180));
const loanAgeDays = Math.max(round(between(0.5, 8) * 365), overdueDays + minGoodPeriodDays);
const disbursementDate = fmt(addDays(today, -loanAgeDays));
const interestRate = +between(product.minRate, product.maxRate).toFixed(2);
const outstandingBalance = round(sanctionedAmount * between(0.25, 0.9));
const overdueFrac = overdueDays === 0 ? 0 : Math.min(0.85, overdueDays / 1200 + between(0, 0.15));
const overdueAmount = round(outstandingBalance * overdueFrac);
const collateralValue = product.secured ? round(sanctionedAmount * between(0.85, 1.25)) : 0;
// Current market value of the pledged security vs its value at sanction: gold trends upward
// over time (real-world gold-price appreciation); crop/agri collateral (KCC) is volatile in
// both directions, matching how "Crop Failure" shows up as an NPA reason; other secured
// collateral (property/vehicle) moves in a narrower band. Unsecured loans have none.
let marketValueFactor = 0;
if (product.code === '05') {
marketValueFactor = between(1.05, 1.35);
} else if (product.code === '06') {
marketValueFactor = between(0.55, 1.2);
} else if (product.secured) {
marketValueFactor = between(0.85, 1.15);
}
const collateralMarketValue = product.secured ? round(collateralValue * marketValueFactor) : 0;
const emiAmount =
product.repaymentType === 'EMI' ? calcEmi(sanctionedAmount, interestRate, product.tenureMonths) : 0;
const drawingPower = product.repaymentType === 'OVERDRAWN' ? round(sanctionedAmount * between(0.75, 0.9)) : 0;
const maturityDate =
product.repaymentType === 'BULLET' ? fmt(addDays(parseDMY(disbursementDate), product.tenureMonths * 30)) : '—';
const npaReason = isNpaStage(stressed) ? pick(NPA_REASONS) : '—';
const recoveryOfficer = isNpaStage(stressed) || isSmaStage(stressed) ? pick(RECOVERY_OFFICERS) : '—';
const legalActionStatus = legalActionFor(stressed);
// Quarter-ago overdue count is capped at how old the loan actually was 90 days back — a loan
// can't have been overdue for longer than it had existed at that point in time.
const drift = round((rng() - 0.4) * 130);
const maxQuarterAgoOverdueDays = Math.max(0, loanAgeDays - 90);
const overdueDaysQuarterAgo = Math.min(Math.max(0, overdueDays - drift), maxQuarterAgoOverdueDays);
const recoveries: RecoveryTxn[] = [];
if (overdueDays > 90 && rng() < 0.5) {
const numRecoveries = 1 + Math.floor(rng() * 3);
for (let r = 0; r < numRecoveries; r++) {
recoveries.push({
date: fmt(addDays(today, -round(between(10, Math.min(overdueDays, 400))))),
amount: round(outstandingBalance * between(0.02, 0.12)),
mode: pick([
'Cash Deposit',
'Cheque',
'NEFT/RTGS',
'Auction / SARFAESI',
'One-Time Settlement'
])
});
}
}
// A recorded recovery IS a payment — if one landed more recently than the formula-based
// last-payment estimate, the account's "last payment" must reflect that, otherwise the two
// fields on the Account Detail screen would visibly contradict each other.
const formulaLastPaymentDate = fmt(addDays(today, -Math.max(5, overdueDays - round(between(0, 20)))));
const latestRecoveryDate = recoveries.reduce<Date | null>((latest, r) => {
const d = parseDMY(r.date);
return !latest || d > latest ? d : latest;
}, null);
const lastPaymentDate =
latestRecoveryDate && latestRecoveryDate > parseDMY(formulaLastPaymentDate)
? fmt(latestRecoveryDate)
: formulaLastPaymentDate;
const collectionTimeline = buildCollectionTimeline(overdueDays, recoveries, today);
const seq = String(1000 + i).padStart(6, '0');
accounts.push({
accountNo: `1${branch.code.slice(2)}${product.code}${seq}`,
customerName: `${pick(FIRST_NAMES)} ${pick(LAST_NAMES)}`,
guarantorName: `${pick(FIRST_NAMES)} ${pick(LAST_NAMES)}`,
branchCode: branch.code,
branchName: branch.name,
productCode: product.code,
productName: product.name,
secured: product.secured,
repaymentType: product.repaymentType,
sanctionedAmount,
disbursementDate,
interestRate,
outstandingBalance,
overdueAmount,
overdueDays,
overdueDaysQuarterAgo,
collateralValue,
collateralMarketValue,
emiAmount,
drawingPower,
maturityDate,
lastPaymentDate,
isLossAsset,
isRestructured,
provisionCoverageRatio: +between(0.75, 1.1).toFixed(2),
recoveries,
collectionTimeline,
npaReason,
recoveryOfficer,
legalActionStatus
});
}
return accounts;
}
// 8 branches x ~80 accounts/branch on average — sized to look like a real cooperative bank's
// loan book rather than a token demo sample. Pagination in the reports UI is what makes a book
// this size usable, not a smaller dataset.
export const LOAN_ACCOUNTS: LoanAccount[] = generateAccounts(640);
// -------------------------------------------------------------------------
// Classification & provisioning (RBI IRAC norms, simplified for a demo book)
// -------------------------------------------------------------------------
export function classifyDays(days: number, isLossAsset: boolean): NpaStage {
if (isLossAsset && days > 1095) {
return 'LOSS';
}
if (days <= 0) {
return 'STANDARD';
}
if (days <= 30) {
return 'SMA0';
}
if (days <= 60) {
return 'SMA1';
}
if (days <= 90) {
return 'SMA2';
}
if (days <= 365) {
return 'SUBSTANDARD';
}
if (days <= 730) {
return 'DOUBTFUL1';
}
if (days <= 1095) {
return 'DOUBTFUL2';
}
return 'DOUBTFUL3';
}
export function currentStage(a: LoanAccount): NpaStage {
return classifyDays(a.overdueDays, a.isLossAsset);
}
export function previousStage(a: LoanAccount): NpaStage {
return classifyDays(a.overdueDaysQuarterAgo, a.isLossAsset && a.overdueDaysQuarterAgo > 1095);
}
export function provisionRatePct(stage: NpaStage, secured: boolean): number {
switch (stage) {
case 'STANDARD':
case 'SMA0':
case 'SMA1':
return 0.4;
case 'SMA2':
return 0.75;
case 'SUBSTANDARD':
return secured ? 15 : 25;
case 'DOUBTFUL1':
return secured ? 25 : 100;
case 'DOUBTFUL2':
return secured ? 40 : 100;
case 'DOUBTFUL3':
case 'LOSS':
return 100;
}
}
export function provisionRequired(a: LoanAccount): number {
const stage = currentStage(a);
if (!isNpaStage(stage)) {
return round(a.outstandingBalance * (provisionRatePct(stage, a.secured) / 100));
}
const securedPortion = Math.min(a.outstandingBalance, a.collateralValue);
const unsecuredPortion = Math.max(0, a.outstandingBalance - a.collateralValue);
const securedRate = provisionRatePct(stage, true) / 100;
const unsecuredRate = provisionRatePct(stage, false) / 100;
return round(securedPortion * securedRate + unsecuredPortion * unsecuredRate);
}
/** Provision held can run a little above or below what's required (over/under-provisioning
* happens in real banks) — but it can never exceed the account's own outstanding balance, since
* a bank cannot hold more provision than the money actually at risk on that one loan. */
export function provisionHeld(a: LoanAccount): number {
return Math.min(a.outstandingBalance, round(provisionRequired(a) * a.provisionCoverageRatio));
}
/** Days elapsed since the account first crossed 90 days overdue (i.e. became NPA) — 0 for any
* account that isn't currently NPA. The single source both interest-reversal and NPA-since-date
* are built from, so neither can ever quietly drift out of step with the other. */
export function daysSinceNpa(a: LoanAccount): number {
if (!isNpaStage(currentStage(a))) {
return 0;
}
return Math.max(0, a.overdueDays - 90);
}
/** Interest income de-recognised because the account has been NPA for longer than 90 days. */
export function interestReversed(a: LoanAccount): number {
const days = daysSinceNpa(a);
if (days <= 0) {
return 0;
}
return round(a.outstandingBalance * (a.interestRate / 100) * (days / 365));
}
/** Date the account first crossed 90 days overdue (i.e. became NPA) — derived from overdueDays,
* never stored separately, so it always agrees with the account's current classification. */
export function npaSinceDate(a: LoanAccount): string {
if (!isNpaStage(currentStage(a))) {
return '—';
}
return fmt(addDays(new Date(), -daysSinceNpa(a)));
}
export function totalRecovered(a: LoanAccount): number {
return a.recoveries.reduce((s, r) => s + r.amount, 0);
}
/**
* Cash recovered against an NPA account is appropriated to the memorandum "undebited interest"
* bucket first — consistent with IRAC norms recognising NPA interest income only on actual cash
* receipt, never on accrual — and only the balance beyond that reduces principal. Capped at the
* interest actually accrued, so this can never show more interest "recovered" than ever accrued.
*/
export function interestRecoveredCash(a: LoanAccount): number {
return Math.min(totalRecovered(a), interestReversed(a));
}
/** Memorandum interest still un-recovered — the "undebited interest" carried outside the
* borrower's loan account (per IRAC norms), less whatever cash has actually been appropriated
* against it so far. */
export function undebitedInterestBalance(a: LoanAccount): number {
return interestReversed(a) - interestRecoveredCash(a);
}
// -------------------------------------------------------------------------
// Recovery Management System — per-repayment-type overdue check, Expected vs
// Actual Recovery, and the collateral-adequacy exception check. `overdueDays`
// stays the single universal "how overdue is this account" signal (so IRAC
// classification never needs a second, type-specific day-counter) — only its
// PLAIN-ENGLISH INTERPRETATION differs by repayment type: an EMI account
// missed its instalment, an OVERDRAWN account has been over its drawing power,
// a BULLET account is past maturity unpaid.
// -------------------------------------------------------------------------
/** True once a bullet loan's maturity date has passed — the point at which the bank checks
* whether the account "self-closed" (fully repaid) or is now overdue. */
export function isBulletMatured(a: LoanAccount): boolean {
return a.repaymentType === 'BULLET' && a.maturityDate !== '—' && parseDMY(a.maturityDate).getTime() <= Date.now();
}
/** A bullet loan past maturity that's still carrying an overdue balance. */
export function isBulletOverdue(a: LoanAccount): boolean {
return isBulletMatured(a) && a.overdueDays > 0;
}
/** A cash-credit/overdraft-style account currently drawn beyond its sanctioned drawing power. */
export function isOverdrawnBreach(a: LoanAccount): boolean {
return a.repaymentType === 'OVERDRAWN' && a.outstandingBalance > a.drawingPower;
}
/** What ought to be recovered this month, by repayment type: the fixed EMI for a term loan, one
* month's interest-servicing for an overdrawn CC/OD/KCC account, or the full outstanding once a
* bullet loan has matured (0 before maturity — nothing is contractually due yet). */
export function expectedRecoveryThisMonth(a: LoanAccount): number {
switch (a.repaymentType) {
case 'EMI':
return a.emiAmount;
case 'OVERDRAWN':
return round((a.outstandingBalance * a.interestRate) / 100 / 12);
case 'BULLET':
return isBulletMatured(a) ? a.outstandingBalance : 0;
}
}
/** What was actually recovered this month — `overdueDays === 0` means the account is fully
* current, i.e. this cycle's EMI/interest/bullet-repayment landed in full; any overdue days at
* all means it didn't, exactly the binary "full amount or mark Overdue" check a real recovery
* desk runs (this demo book doesn't model partial-instalment part-payments). */
export function actualRecoveryThisMonth(a: LoanAccount): number {
if (a.repaymentType === 'BULLET') {
return isBulletMatured(a) && a.overdueDays === 0 ? a.outstandingBalance : 0;
}
return a.overdueDays === 0 ? expectedRecoveryThisMonth(a) : 0;
}
/** Current collateral cover — market value of security as a % of outstanding balance. 0 for an
* unsecured loan (no collateral to speak of). */
export function securityCoverageRatio(a: LoanAccount): number {
if (!a.secured || a.outstandingBalance <= 0) {
return 0;
}
return (a.collateralMarketValue / a.outstandingBalance) * 100;
}
/** True when a secured loan's CURRENT security market value has fallen below what's actually
* outstanding — the "security value is above loan" exception check every recovery review runs. */
export function isUndersecured(a: LoanAccount): boolean {
return a.secured && a.collateralMarketValue < a.outstandingBalance;
}
export function movementLabel(prev: NpaStage, curr: NpaStage): string {
if (prev === curr) {
return 'No Change';
}
const prevIdx = STAGE_ORDER.indexOf(prev);
const currIdx = STAGE_ORDER.indexOf(curr);
if (currIdx > prevIdx) {
return !isNpaStage(prev) && isNpaStage(curr) ? 'Fresh Slippage to NPA' : 'Downgraded';
}
return isNpaStage(prev) && !isNpaStage(curr) ? 'Upgraded to Standard' : 'Improved';
}
// -------------------------------------------------------------------------
// Report menu metadata — drives both the shell mega-menu routerLinks and the
// in-page group/report tab rows, so a menu item and its screen can never
// point at different labels.
// -------------------------------------------------------------------------
export type ReportGroupId = 'reports' | 'analysis' | 'classification' | 'recovery-mgmt';
export interface ReportMeta {
id: string;
group: ReportGroupId;
label: string;
}
export const REPORT_META: ReportMeta[] = [
{ id: 'interest', group: 'reports', label: 'NPA Report' },
{ id: 'status', group: 'reports', label: 'NPA Status Report' },
{ id: 'recovery', group: 'reports', label: 'NPA Recovery Report' },
{ id: 'summary', group: 'reports', label: 'NPA Summary Report' },
{ id: 'account-detail', group: 'analysis', label: 'NPA Account Detail Report' },
{ id: 'movement', group: 'analysis', label: 'NPA Movement Report' },
{ id: 'provision', group: 'analysis', label: 'NPA with Provision and Required Provision' },
{ id: 'undebited-interest', group: 'analysis', label: 'Undebited Interest Recovery Report' },
{ id: 'probable', group: 'classification', label: 'Probable NPA' },
{ id: 'sma', group: 'classification', label: 'SMA Report' },
{ id: 'recovery-dashboard', group: 'recovery-mgmt', label: 'Recovery Management Dashboard' },
{ id: 'daywise-overdue', group: 'recovery-mgmt', label: 'Day-wise Overdue Report' },
{ id: 'daywise-overdrawn', group: 'recovery-mgmt', label: 'Day-wise Overdrawn Report' },
{ id: 'agri-gold-value', group: 'recovery-mgmt', label: 'Agriculture / Gold Market Value Report' },
{ id: 'exception-check', group: 'recovery-mgmt', label: 'Exception Check — Security Value vs Loan' }
];
export const GROUP_LABELS: Record<ReportGroupId, string> = {
reports: 'NPA Reports',
analysis: 'NPA Analysis',
classification: 'NPA Classification',
'recovery-mgmt': 'Recovery Management'
};
export const DEFAULT_REPORT_ID = 'summary';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
/**
* 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 { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { NpaReportsComponent } from './npa-reports/npa-reports.component';
import { DEFAULT_REPORT_ID } from './npa-data';
const routes: Routes = [
Route.withShell([
{
path: 'npa',
data: { title: 'NPA', breadcrumb: 'NPA' },
children: [
{
path: 'reports',
redirectTo: `reports/${DEFAULT_REPORT_ID}`,
pathMatch: 'full'
},
{
path: 'reports/:reportId',
component: NpaReportsComponent,
data: { title: 'NPA Reports', breadcrumb: 'NPA Reports' }
}
]
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class NpaRoutingModule {}
+452
View File
@@ -0,0 +1,452 @@
/**
* 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/.
*/
// NPA-specific status colouring layered on top of ../cashier/cashier-shared.scss (pg-*, cctr-*)
// and ../investment/investment-shared.scss (inv-kpis, inv-form-*, inv-toast — reused as-is since
// they are the same generic "KPI tile / entry form / toast" primitives, not Investment-specific).
.npa-report-note {
max-width: none;
}
.npa-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
&--standard {
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
}
&--sma0 {
background: #fefce8;
color: #854d0e;
border: 1px solid #fde68a;
}
&--sma1 {
background: #fff7ed;
color: #9a3412;
border: 1px solid #fed7aa;
}
&--sma2 {
background: #fff1f0;
color: #b91c1c;
border: 1px solid #fecdd3;
}
&--substandard {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
&--doubtful1 {
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fca5a5;
}
&--doubtful2 {
background: #fdf2f8;
color: #9d174d;
border: 1px solid #fbcfe8;
}
&--doubtful3 {
background: #f5f3ff;
color: #5b21b6;
border: 1px solid #ddd6fe;
}
&--loss {
background: #1f2937;
color: #f9fafb;
border: 1px solid #1f2937;
}
}
.npa-kpis--ratio {
.inv-kpi__value {
&.npa-warn {
color: #dc2626;
}
}
}
// Recovery Management Dashboard's 3 loan-type panels — same .inv-kpi tile, just a 3-wide grid
// instead of the portfolio strip's 4-wide one.
.npa-kpis--three {
grid-template-columns: repeat(3, 1fr);
}
// Account Detail header — title/badge on the left, the Recovery Journey trigger pinned right.
.npa-detail-header {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.npa-timeline-trigger {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 7px;
background: linear-gradient(135deg, #eef2ff, #e0e7ff);
color: #4338ca;
border: 1px solid #c7d2fe;
font-weight: 700;
text-transform: none;
letter-spacing: normal;
transition:
box-shadow 0.15s ease,
transform 0.15s ease;
&:hover {
background: linear-gradient(135deg, #e0e7ff, #c7d2fe);
box-shadow: 0 4px 14px rgb(67 56 202 / 22%);
transform: translateY(-1px);
}
}
// ---------------------------------------------------------------------------
// Recovery / Collection Journey popup — a date-led timeline of every SMS, call,
// visit, notice and payment run against one account, opened from the Account
// Detail Report's "Recovery Journey" button.
// ---------------------------------------------------------------------------
.npa-timeline-backdrop {
position: fixed;
inset: 0;
background: rgb(15 23 42 / 55%);
backdrop-filter: blur(3px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 24px;
animation: npa-timeline-fade-in 0.15s ease-out;
}
.npa-timeline-modal {
background: #fff;
border-radius: 18px;
width: 640px;
max-width: 100%;
max-height: min(720px, 88vh);
display: flex;
flex-direction: column;
box-shadow: 0 24px 60px rgb(15 23 42 / 35%);
animation: npa-timeline-slide-up 0.2s cubic-bezier(0.16, 1, 0.3, 1);
overflow: hidden;
&__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 20px 22px;
border-bottom: 1px solid #e2e8f0;
background: linear-gradient(180deg, #f8fafc, #fff);
}
&__title {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
h2 {
margin: 0;
font-size: 17px;
font-weight: 800;
color: #0f172a;
}
}
&__eyebrow {
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
color: #4338ca;
}
&__meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 12px;
color: #64748b;
}
&__branch::before,
&__count::before {
content: '·';
margin-right: 8px;
color: #cbd5e1;
}
&__close {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 999px;
border: 1px solid #e2e8f0;
background: #fff;
color: #64748b;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease;
&:hover {
background: #f1f5f9;
color: #0f172a;
}
}
&__body {
overflow-y: auto;
padding: 22px;
}
}
@keyframes npa-timeline-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes npa-timeline-slide-up {
from {
opacity: 0;
transform: translateY(14px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.npa-timeline-backdrop,
.npa-timeline-modal {
animation: none;
}
}
// The timeline list itself — a connecting vertical line runs behind the icon badges.
.npa-timeline {
list-style: none;
margin: 0;
padding: 0;
}
.npa-timeline-item {
position: relative;
display: flex;
gap: 14px;
padding-bottom: 22px;
// The connecting line spans from just under this item's icon to just above the next one's —
// anchored to the item itself (not the fixed-size icon) via top+bottom, so it always reaches
// exactly as far as the gap to the next entry, however tall this entry's text runs.
&:not(:last-child)::before {
content: '';
position: absolute;
top: 34px;
bottom: -22px;
left: 17px;
width: 2px;
background: #e2e8f0;
}
&:last-child {
padding-bottom: 0;
}
&__icon {
position: relative;
flex-shrink: 0;
width: 34px;
height: 34px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
z-index: 1;
}
&--neutral &__icon {
background: #eff6ff;
color: #1d4ed8;
}
&--alert &__icon {
background: #fef2f2;
color: #b91c1c;
}
&--positive &__icon {
background: #f0fdf4;
color: #15803d;
}
&__body {
flex: 1;
min-width: 0;
padding-top: 4px;
}
&__row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
&__type {
font-weight: 700;
font-size: 13.5px;
color: #0f172a;
}
&__date {
font-size: 11.5px;
color: #94a3b8;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
&__remarks {
margin: 3px 0 6px;
font-size: 12.5px;
color: #475569;
line-height: 1.45;
}
&__channel {
display: inline-block;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
color: #64748b;
background: #f1f5f9;
padding: 2px 8px;
border-radius: 6px;
}
}
.npa-timeline-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 40px 20px;
color: #64748b;
text-align: center;
fa-icon {
font-size: 26px;
color: #22c55e;
}
p {
margin: 0;
font-size: 13px;
}
}
.npa-detail-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px 20px;
padding: 18px;
}
.npa-detail-field {
display: flex;
flex-direction: column;
gap: 3px;
dt {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #64748b;
}
dd {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #0d1b2a;
font-variant-numeric: tabular-nums;
}
}
// Wraps tables with more columns than comfortably fit — .pg-card clips overflow with
// border-radius, so the scroll container has to live one level in, around the <table> only.
.pg-table-scroll {
overflow-x: auto;
}
// Lets a single field span the whole .inv-form-grid row instead of being squeezed into one
// ~220px column — needed for the Account Detail search box, whose "code — name (branch)"
// option text is long enough to overflow a narrow column even with ellipsis truncation.
.npa-form-field--full {
grid-column: 1 / -1;
}
.pg-pagination {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
padding: 10px 16px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
font-size: 12.5px;
color: #64748b;
&__controls {
display: flex;
align-items: center;
gap: 10px;
}
&__page {
font-weight: 600;
color: #0d1b2a;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
}
+22
View File
@@ -0,0 +1,22 @@
/**
* 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 { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { NpaRoutingModule } from './npa-routing.module';
import { NpaReportsComponent } from './npa-reports/npa-reports.component';
@NgModule({
imports: [
SharedModule,
NpaRoutingModule,
NpaReportsComponent
]
})
export class NpaModule {}
@@ -0,0 +1,237 @@
/**
* 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, ElementRef, forwardRef, HostListener, Input, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
export interface SearchSelectOption {
value: string;
label: string;
/** What typed text is matched against — defaults to `label` when omitted, but a caller can
* pass a richer string (e.g. account no + customer name + branch) so a search hits fields
* that aren't all visible in the collapsed label. */
searchText?: string;
}
/**
* Drop-in replacement for a plain `<select>` when the option list is long enough that scanning
* it is impractical (e.g. hundreds of loan accounts) — a text box that filters the option list
* as you type, instead of a native dropdown you can only scroll through. Selection only commits
* on click; typing without picking an option reverts the box to the last committed selection on
* blur, so the bound ngModel value never drifts from an actual, valid option.
*
* The option list is positioned `fixed` and anchored to the input's own on-screen coordinates
* (recomputed on open/scroll/resize) rather than `absolute` inside the component — most of this
* app's cards use `overflow: hidden` for rounded corners, which would otherwise clip a dropdown
* that extends past the card's edge.
*
* Usage: <mifosx-search-select [options]="opts" [(ngModel)]="selectedValue"></mifosx-search-select>
*/
@Component({
selector: 'mifosx-search-select',
standalone: true,
imports: [
CommonModule,
FormsModule
],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SearchSelectComponent),
multi: true
}
],
template: `
<div class="inv-search-select">
<input
#inputEl
type="text"
class="inv-search-select__input"
[value]="query"
[placeholder]="placeholder"
[disabled]="disabled"
(input)="onInput($event)"
(focus)="onFocus()"
(blur)="onBlur()"
/>
@if (open) {
<ul
class="inv-search-select__list"
[style.top.px]="listTop"
[style.left.px]="listLeft"
[style.width.px]="listWidth"
(mousedown)="$event.preventDefault()"
>
@for (o of filteredOptions; track o.value) {
<li
class="inv-search-select__option"
[class.inv-search-select__option--active]="o.value === value"
(click)="selectOption(o)"
>
{{ o.label }}
</li>
} @empty {
<li class="inv-search-select__empty">No matches</li>
}
</ul>
}
</div>
`,
styles: [
`
/* Angular view encapsulation stops the host page's shared styles from reaching inside this
* component's own template, so its look is re-declared here rather than inherited. */
:host {
display: block;
width: 100%;
}
.inv-search-select {
position: relative;
width: 100%;
}
.inv-search-select__input {
width: 100%;
height: 36px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
color: #0d1b2a;
font-family: inherit;
background: #fff;
box-sizing: border-box;
}
.inv-search-select__list {
position: fixed;
z-index: 2000;
max-height: 260px;
overflow-y: auto;
margin: 0;
padding: 4px;
list-style: none;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(13, 27, 42, 0.18);
}
.inv-search-select__option {
padding: 8px 10px;
border-radius: 6px;
font-size: 13px;
color: #0d1b2a;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.inv-search-select__option:hover,
.inv-search-select__option--active {
background: #eef2ff;
}
.inv-search-select__empty {
padding: 8px 10px;
font-size: 12px;
color: #94a3b8;
}
`
]
})
export class SearchSelectComponent implements ControlValueAccessor {
@Input() options: SearchSelectOption[] = [];
@Input() placeholder = 'Search…';
@ViewChild('inputEl') private inputEl?: ElementRef<HTMLInputElement>;
value = '';
query = '';
open = false;
disabled = false;
listTop = 0;
listLeft = 0;
listWidth = 0;
private onChangeFn: (v: string) => void = () => {};
onTouched: () => void = () => {};
get filteredOptions(): SearchSelectOption[] {
const q = this.query.trim().toLowerCase();
if (!q) {
return this.options;
}
return this.options.filter((o) => (o.searchText || o.label).toLowerCase().includes(q));
}
private labelFor(value: string): string {
return this.options.find((o) => o.value === value)?.label ?? '';
}
writeValue(v: string): void {
this.value = v ?? '';
this.query = this.labelFor(this.value);
}
registerOnChange(fn: (v: string) => void): void {
this.onChangeFn = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
/** Anchors the fixed-position list to the input's current on-screen box. */
private updateListPosition(): void {
const el = this.inputEl?.nativeElement;
if (!el) {
return;
}
const rect = el.getBoundingClientRect();
this.listTop = rect.bottom + 4;
this.listLeft = rect.left;
this.listWidth = rect.width;
}
@HostListener('window:scroll')
@HostListener('window:resize')
onWindowChange(): void {
if (this.open) {
this.updateListPosition();
}
}
onInput(event: Event): void {
this.query = (event.target as HTMLInputElement).value;
this.open = true;
this.updateListPosition();
}
onFocus(): void {
this.open = true;
this.updateListPosition();
}
onBlur(): void {
this.open = false;
this.onTouched();
// Discard any uncommitted typed text — only a clicked option ever changes `value`.
this.query = this.labelFor(this.value);
}
selectOption(o: SearchSelectOption): void {
this.value = o.value;
this.query = o.label;
this.open = false;
this.onChangeFn(this.value);
this.onTouched();
}
}
@@ -21,6 +21,22 @@
</mat-card-header>
<mat-card-content class="aml-card-content">
<!-- Separate the rules by the window they are measured over -->
<div class="period-filters">
<span class="period-filters-label">Applies over</span>
<button
*ngFor="let p of periodFilters"
type="button"
class="period-chip"
[class.active]="activePeriod === p.key"
[disabled]="periodCount(p.key) === 0"
(click)="setPeriod(p.key)"
>
{{ p.label }}
<span class="period-chip-n">{{ periodCount(p.key) }}</span>
</button>
</div>
<table mat-table [dataSource]="rules" class="aml-table mat-elevation-z1">
<!-- Order Position Column -->
<ng-container matColumnDef="orderPosition">
@@ -45,6 +61,39 @@
</td>
</ng-container>
<!-- What the min/max figures are measured against -->
<ng-container matColumnDef="limitBasis">
<th mat-header-cell *matHeaderCellDef>Limit Basis</th>
<td mat-cell *matCellDef="let element" class="basis-cell">
{{ getLimitBasis(element) }}
<span *ngIf="getNonNumericCriterion(element)" class="basis-sub">
{{ getNonNumericCriterion(element) }}
</span>
</td>
</ng-container>
<!-- Minimum Limit Column -->
<ng-container matColumnDef="minLimit">
<th mat-header-cell *matHeaderCellDef class="col-right">Minimum Limit</th>
<td mat-cell *matCellDef="let element" class="col-right amount-cell">{{ getMinLimit(element) }}</td>
</ng-container>
<!-- Maximum Limit Column -->
<ng-container matColumnDef="maxLimit">
<th mat-header-cell *matHeaderCellDef class="col-right">Maximum Limit</th>
<td mat-cell *matCellDef="let element" class="col-right amount-cell">
<span [class.no-ceiling]="getMaxLimit(element) === 'No upper limit'">{{ getMaxLimit(element) }}</span>
</td>
</ng-container>
<!-- Observation window the rule is measured over -->
<ng-container matColumnDef="appliesOver">
<th mat-header-cell *matHeaderCellDef class="col-center">Applies Over</th>
<td mat-cell *matCellDef="let element" class="col-center">
<span [ngClass]="periodClass(element)">{{ periodLabel(element) }}</span>
</td>
</ng-container>
<!-- Reason Column -->
<ng-container matColumnDef="reason">
<th mat-header-cell *matHeaderCellDef>Rule Reason / Criteria</th>
@@ -114,6 +114,143 @@
max-width: 400px;
}
.col-right {
text-align: right;
justify-content: flex-end;
}
/* What the min/max amounts are measured against. */
.basis-cell {
color: #334155;
font-size: 12px;
font-weight: 600;
white-space: normal;
max-width: 150px;
line-height: 1.3;
}
.basis-sub {
display: block;
font-size: 11px;
font-weight: 400;
color: #64748b;
}
.amount-cell {
font-family: 'DM Mono', monospace;
font-size: 12.5px;
color: #0f172a;
white-space: nowrap;
}
/* An open-ended threshold rule has a floor but no ceiling. */
.no-ceiling {
font-family: inherit;
font-size: 11px;
font-style: italic;
color: #94a3b8;
}
/* ── Observation-window badges ─────────────────────────────────────────── */
.period-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
letter-spacing: 0.02em;
&.period-txn {
background: #e2e8f0;
color: #334155;
}
&.period-day {
background: #dbeafe;
color: #1d4ed8;
}
&.period-week {
background: #fef3c7;
color: #b45309;
}
&.period-month {
background: #ede9fe;
color: #6d28d9;
}
&.period-other {
background: #fee2e2;
color: #b91c1c;
}
}
/* Chips that separate the rule list by window. */
.period-filters {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 0 0 14px;
}
.period-filters-label {
font-size: 11px;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-right: 2px;
}
.period-chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 13px;
border: 1px solid #cbd5e1;
background: #fff;
border-radius: 999px;
font-size: 12.5px;
font-weight: 600;
color: #334155;
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
&:hover:not(:disabled) {
border-color: #1a237e;
color: #1a237e;
}
&.active {
background: #1a237e;
border-color: #1a237e;
color: #fff;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.period-chip-n {
font-size: 11px;
font-weight: 700;
padding: 1px 7px;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
.period-chip.active & {
background: rgb(255 255 255 / 22%);
color: #fff;
}
}
/* Badges for Risk Category */
.risk-badge {
display: inline-flex;
@@ -42,23 +42,185 @@ export class AmlRulesComponent implements OnInit {
'orderPosition',
'ruleName',
'riskCategory',
'limitBasis',
'minLimit',
'maxLimit',
'appliesOver',
'reason',
'amlCheckStatus',
'bannedEntityMatch'
];
/** Every rule as returned by the API, before the period filter is applied. */
private allRules: any[] = [];
/** Period buckets the rules can be separated into. */
readonly periodFilters: { key: string; label: string }[] = [
{ key: '', label: 'All rules' },
{ key: 'TXN', label: 'Per transaction' },
{ key: 'DAY', label: 'Daily' },
{ key: 'WEEK', label: 'Weekly' },
{ key: 'MONTH', label: 'Monthly' },
{ key: 'OTHER', label: 'Other window' }
];
activePeriod = '';
@ViewChild(MatPaginator) paginator!: MatPaginator;
ngOnInit(): void {
this.clientsService.getClientRiskRules().subscribe({
next: (data) => {
this.rules.data = data || [];
this.allRules = data || [];
this.applyPeriodFilter();
this.rules.paginator = this.paginator;
},
error: () => {}
});
}
setPeriod(key: string): void {
this.activePeriod = key;
this.applyPeriodFilter();
}
private applyPeriodFilter(): void {
this.rules.data = this.activePeriod
? this.allRules.filter((r) => this.periodKey(r) === this.activePeriod)
: this.allRules;
if (this.rules.paginator) {
this.rules.paginator.firstPage();
}
}
/** How many rules fall in each period bucket — shown on the filter chips. */
periodCount(key: string): number {
return key ? this.allRules.filter((r) => this.periodKey(r) === key).length : this.allRules.length;
}
/**
* Buckets a rule by the observation window it is measured over.
* A rule with no window is evaluated on the single transaction itself, so it is
* in force every day rather than accumulating over a period.
*/
periodKey(rule: any): string {
const days = rule.txnWindowDays;
if (days == null || days === 0) {
return 'TXN';
}
if (days === 1) {
return 'DAY';
}
if (days === 7) {
return 'WEEK';
}
if (days >= 28 && days <= 31) {
return 'MONTH';
}
return 'OTHER';
}
periodLabel(rule: any): string {
const days = rule.txnWindowDays;
switch (this.periodKey(rule)) {
case 'TXN':
return 'Per transaction';
case 'DAY':
return 'Daily';
case 'WEEK':
return 'Weekly';
case 'MONTH':
return 'Monthly';
default:
return `${days} days`;
}
}
periodClass(rule: any): string {
return 'period-badge period-' + this.periodKey(rule).toLowerCase();
}
/** Sub-caption telling the reader what the min/max figures actually measure. */
getLimitBasis(rule: any): string {
if (rule.minMonthlyIncome > 0 || rule.maxMonthlyIncome > 0) {
return 'Monthly Income';
}
if (rule.minAnnualIncome > 0 || rule.maxAnnualIncome > 0) {
return 'Annual Income';
}
if (rule.minAnnualTurnover > 0 || rule.maxAnnualTurnover > 0) {
return 'Annual Turnover';
}
if (rule.txnThresholdAmountMediumRisk != null || rule.txnThresholdAmountHighRisk != null) {
return 'Txn Amount (tiered)';
}
if (rule.txnThresholdAmount != null) {
return 'Txn Amount';
}
if (rule.occupationName) {
return 'Occupation';
}
if (rule.occupationCategoryName) {
return 'Occupation Category';
}
return 'Behavioural pattern';
}
private inr(n: number): string {
return '₹' + Number(n).toLocaleString('en-IN');
}
/** Lower bound of the rule, or an em dash where the criterion is not numeric. */
getMinLimit(rule: any): string {
if (rule.minMonthlyIncome > 0) {
return this.inr(rule.minMonthlyIncome);
}
if (rule.minAnnualIncome > 0) {
return this.inr(rule.minAnnualIncome);
}
if (rule.minAnnualTurnover > 0) {
return this.inr(rule.minAnnualTurnover);
}
// Declarative band from m_aml_transaction_rule takes precedence over the raw threshold.
if (rule.txnMinLimitAmount != null) {
return this.inr(rule.txnMinLimitAmount);
}
if (rule.txnThresholdAmount != null) {
return this.inr(rule.txnThresholdAmount);
}
return '—';
}
/** Upper bound, or "No upper limit" where the rule is genuinely open-ended. */
getMaxLimit(rule: any): string {
if (rule.maxMonthlyIncome > 0) {
return this.inr(rule.maxMonthlyIncome);
}
if (rule.maxAnnualIncome > 0) {
return this.inr(rule.maxAnnualIncome);
}
if (rule.maxAnnualTurnover > 0) {
return this.inr(rule.maxAnnualTurnover);
}
if (rule.txnMaxLimitAmount != null) {
return this.inr(rule.txnMaxLimitAmount);
}
// A floor with no configured ceiling means the rule has no upper bound.
if (
rule.minMonthlyIncome > 0 ||
rule.minAnnualIncome > 0 ||
rule.minAnnualTurnover > 0 ||
rule.txnThresholdAmount != null
) {
return 'No upper limit';
}
return '—';
}
/** Occupation-driven rules carry no amount; show the criterion instead. */
getNonNumericCriterion(rule: any): string {
return rule.occupationName || rule.occupationCategoryName || '';
}
getRiskCategoryLabel(category: number): string {
switch (category) {
case 1:
@@ -0,0 +1,126 @@
<!--
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="glm-root">
<div class="glm-head">
<div>
<h3>Map General Ledger accounts</h3>
<p>{{ data.returnCode }} &middot; {{ data.lineLabel }}</p>
</div>
<button class="glm-btn" (click)="cancel()"><fa-icon icon="times"></fa-icon></button>
</div>
<!-- Live comparison between the ledger and the figure on the return -->
<div
class="glm-compare"
[class.glm-compare--match]="selected.length && matchesFiled"
[class.glm-compare--diff]="selected.length && !matchesFiled && variance !== null"
>
<div class="glm-fig">
<span class="glm-fig-l">On the return</span>
<span class="glm-fig-v">{{ data.filedValue === null ? '—' : (data.filedValue | number: '1.2-2') }}</span>
</div>
<fa-icon icon="exchange-alt" class="glm-fig-ic"></fa-icon>
<div class="glm-fig">
<span class="glm-fig-l">From the ledger</span>
<span class="glm-fig-v">{{ selected.length ? (derivedValue | number: '1.2-2') : '—' }}</span>
</div>
@if (selected.length && variance !== null) {
<div class="glm-fig glm-fig--var">
<span class="glm-fig-l">Variance</span>
<span class="glm-fig-v">{{ variance | number: '1.2-2' }}</span>
</div>
}
<div class="glm-compare-note">
@if (!selected.length) {
Select accounts below to derive this line from the ledger.
} @else if (matchesFiled) {
Ledger agrees with the filed figure.
} @else {
Ledger differs from the filed figure — the mapped value will be shown on the return.
}
</div>
</div>
<!-- Currently pinned accounts -->
<div class="glm-selected">
<div class="glm-selected-h">
<span>Mapped accounts ({{ selected.length }})</span>
@if (selected.length) {
<button class="glm-link" (click)="clearAll()">Remove all</button>
}
</div>
@if (selected.length) {
<div class="glm-chips">
@for (code of selected; track code) {
<span class="glm-chip">
<span class="mono">{{ code }}</span> {{ accountName(code) }}
<button class="glm-chip-x" (click)="removeSelected(code)" [attr.aria-label]="'Remove ' + code">
<fa-icon icon="times"></fa-icon>
</button>
</span>
}
</div>
} @else {
<p class="glm-empty">No accounts mapped — this line still uses the figure filed on the return.</p>
}
</div>
<!-- Chart of accounts picker -->
<div class="glm-filters">
<input class="glm-inp" type="text" placeholder="Search by code, name or group…" [(ngModel)]="search" />
<select class="glm-inp glm-inp--sm" [(ngModel)]="typeFilter">
<option value="">All types</option>
@for (t of types; track t) {
<option [value]="t">{{ t }}</option>
}
</select>
</div>
<div class="glm-list">
<table class="glm-tbl">
<thead>
<tr>
<th class="ta-c">Map</th>
<th>GL Code</th>
<th>Account</th>
<th>Type</th>
<th>Group</th>
</tr>
</thead>
<tbody>
@for (a of filtered; track a.code) {
<tr [class.on]="isSelected(a.code)" (click)="toggle(a.code)">
<td class="ta-c">
<span class="glm-check" [class.on]="isSelected(a.code)">
@if (isSelected(a.code)) {
<fa-icon icon="check-circle"></fa-icon>
}
</span>
</td>
<td class="mono">{{ a.code }}</td>
<td>{{ a.name }}</td>
<td>
<span class="glm-type" [class]="'t-' + a.type.toLowerCase()">{{ a.type }}</span>
</td>
<td class="muted">{{ a.group }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="glm-empty-row">No accounts match that search.</td>
</tr>
}
</tbody>
</table>
</div>
<div class="glm-actions">
<button class="glm-btn" (click)="cancel()">Cancel</button>
<button class="glm-btn glm-btn-primary" (click)="save()">Save mapping ({{ selected.length }})</button>
</div>
</div>
@@ -0,0 +1,358 @@
/**
* 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 {
--m-navy: #002f5f;
--m-green: #15803d;
--m-green-l: #dcfce7;
--m-amber: #b45309;
--m-amber-l: #fef3c7;
--m-red: #c0392b;
--m-red-l: #fdecea;
--m-g50: #f8fafc;
--m-g100: #f1f5f9;
--m-g200: #e2e8f0;
--m-g300: #cbd5e1;
--m-g500: #64748b;
--m-g700: #334155;
--m-g900: #0f172a;
display: block;
font-family: 'DM Sans', system-ui, sans-serif;
color: var(--m-g900);
}
.glm-root {
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px 20px;
max-height: 88vh;
}
.glm-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
h3 {
margin: 0;
font-size: 17px;
font-weight: 700;
}
p {
margin: 2px 0 0;
font-size: 12px;
color: var(--m-g500);
}
}
/* Ledger vs filed figure */
.glm-compare {
display: flex;
align-items: center;
gap: 18px;
flex-wrap: wrap;
padding: 12px 16px;
border-radius: 10px;
background: var(--m-g100);
border: 1px solid var(--m-g200);
&--match {
background: var(--m-green-l);
border-color: #86efac;
}
&--diff {
background: var(--m-amber-l);
border-color: #fcd34d;
}
}
.glm-fig {
display: flex;
flex-direction: column;
&--var .glm-fig-v {
color: var(--m-amber);
}
}
.glm-fig-l {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--m-g500);
}
.glm-fig-v {
font-family: 'DM Mono', monospace;
font-size: 16px;
font-weight: 700;
}
.glm-fig-ic {
color: var(--m-g500);
}
.glm-compare-note {
flex: 1 1 220px;
font-size: 12px;
color: var(--m-g700);
}
/* Pinned accounts */
.glm-selected-h {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--m-g500);
margin-bottom: 7px;
}
.glm-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.glm-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 6px 4px 10px;
border-radius: 999px;
background: #e0f2fe;
color: #075985;
font-size: 12px;
font-weight: 500;
}
.glm-chip-x {
border: none;
background: rgb(7 89 133 / 12%);
color: #075985;
width: 18px;
height: 18px;
border-radius: 50%;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
&:hover {
background: var(--m-red);
color: #fff;
}
}
.glm-empty {
margin: 0;
font-size: 12px;
color: var(--m-g500);
font-style: italic;
}
/* Picker */
.glm-filters {
display: flex;
gap: 8px;
}
.glm-inp {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--m-g200);
border-radius: 8px;
font-size: 13px;
font-family: inherit;
&--sm {
flex: 0 0 160px;
}
&:focus {
outline: none;
border-color: var(--m-navy);
}
}
.glm-list {
flex: 1;
min-height: 180px;
max-height: 40vh;
overflow: auto;
border: 1px solid var(--m-g200);
border-radius: 8px;
}
.glm-tbl {
width: 100%;
border-collapse: collapse;
th {
position: sticky;
top: 0;
z-index: 1;
text-align: left;
padding: 8px 12px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--m-g500);
background: var(--m-g50);
border-bottom: 1px solid var(--m-g200);
}
td {
padding: 7px 12px;
border-bottom: 1px solid var(--m-g100);
font-size: 13px;
color: var(--m-g700);
}
tbody tr {
cursor: pointer;
&:hover td {
background: var(--m-g50);
}
&.on td {
background: #eff6ff;
}
}
}
.glm-check {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: 2px solid var(--m-g300);
border-radius: 5px;
color: transparent;
font-size: 11px;
&.on {
background: var(--m-green);
border-color: var(--m-green);
color: #fff;
}
}
.glm-type {
display: inline-block;
padding: 1px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
background: var(--m-g100);
color: var(--m-g700);
&.t-asset {
background: #dbeafe;
color: #1d4ed8;
}
&.t-liability {
background: #e0f4f0;
color: #0e7e6b;
}
&.t-income {
background: var(--m-green-l);
color: var(--m-green);
}
&.t-expense {
background: var(--m-amber-l);
color: var(--m-amber);
}
&.t-contingent {
background: var(--m-red-l);
color: var(--m-red);
}
}
.glm-empty-row {
text-align: center;
padding: 26px;
color: var(--m-g500);
font-style: italic;
}
.mono {
font-family: 'DM Mono', monospace;
font-size: 12px;
}
.muted {
color: var(--m-g500);
font-size: 11px;
}
.ta-c {
text-align: center;
}
/* Actions */
.glm-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.glm-btn {
padding: 8px 16px;
border: 1px solid var(--m-g300);
background: #fff;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: var(--m-g700);
cursor: pointer;
font-family: inherit;
&:hover {
background: var(--m-g50);
}
}
.glm-btn-primary {
background: var(--m-navy);
border-color: var(--m-navy);
color: #fff;
&:hover {
background: #013a75;
color: #fff;
}
}
.glm-link {
border: none;
background: none;
color: var(--m-red);
font-size: 11px;
font-weight: 700;
cursor: pointer;
text-transform: none;
letter-spacing: 0;
}
@@ -0,0 +1,121 @@
/**
* 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, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { GlAccount } from 'app/gl-reports/gl-reports-data';
import { GlDateRange, glValueForCodes } from 'app/gl-reports/gl-reports.compute';
import { RETURN_SCALE, glAccountsForPicker } from './gl-mapping';
export interface GlMappingDialogData {
returnCode: string;
lineLabel: string;
/** The figure currently printed on the return, in thousands. */
filedValue: number | null;
selected: string[];
range: GlDateRange;
branchCode: string;
}
/**
* Lets a user pin one or more GL accounts to a single return line, showing live what
* the ledger would produce for the selection against the figure currently on the return.
*/
@Component({
selector: 'mifosx-gl-mapping-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
FaIconComponent
],
templateUrl: './gl-mapping-dialog.component.html',
styleUrls: ['./gl-mapping-dialog.component.scss']
})
export class GlMappingDialogComponent {
private dialogRef = inject(MatDialogRef<GlMappingDialogComponent>);
data = inject<GlMappingDialogData>(MAT_DIALOG_DATA);
readonly accounts: GlAccount[] = glAccountsForPicker();
search = '';
typeFilter = '';
selected: string[] = [];
readonly types = [
'ASSET',
'LIABILITY',
'INCOME',
'EXPENSE',
'CONTINGENT'
];
constructor() {
this.selected = [...(this.data.selected ?? [])];
}
get filtered(): GlAccount[] {
const q = this.search.trim().toLowerCase();
return this.accounts.filter((a) => {
if (this.typeFilter && a.type !== this.typeFilter) {
return false;
}
if (!q) {
return true;
}
return a.code.includes(q) || a.name.toLowerCase().includes(q) || a.group.toLowerCase().includes(q);
});
}
isSelected(code: string): boolean {
return this.selected.includes(code);
}
toggle(code: string): void {
this.selected = this.isSelected(code) ? this.selected.filter((c) => c !== code) : [
...this.selected,
code
];
}
removeSelected(code: string): void {
this.selected = this.selected.filter((c) => c !== code);
}
clearAll(): void {
this.selected = [];
}
accountName(code: string): string {
return this.accounts.find((a) => a.code === code)?.name ?? code;
}
/** Live ledger figure for the current selection, in the return's scale. */
get derivedValue(): number {
return glValueForCodes(this.selected, this.data.range, this.data.branchCode) / RETURN_SCALE;
}
get variance(): number | null {
return this.data.filedValue == null ? null : this.derivedValue - this.data.filedValue;
}
get matchesFiled(): boolean {
return this.variance !== null && Math.abs(this.variance) < 0.005;
}
save(): void {
this.dialogRef.close(this.selected);
}
cancel(): void {
this.dialogRef.close(undefined);
}
}
+140
View File
@@ -0,0 +1,140 @@
/**
* 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 { inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { BranchScopeService } from 'app/core/branch-scope.service';
import { GlDateRange } from 'app/gl-reports/gl-reports.compute';
import { GL_BRANCHES } from 'app/gl-reports/gl-reports-data';
import { GlMappingDialogComponent, GlMappingDialogData } from './gl-mapping-dialog.component';
import {
clearReturnMappings,
getMapping,
lineKeyFor,
mappedLineCount,
mappedValue,
removeGl,
setMapping
} from './gl-mapping';
/**
* Shared GL-mapping behaviour for the OSS return screens.
*
* Each return line can be pinned to GL accounts; where it is, the figure shown comes
* from the ledger rather than the static filed value. Subclasses only supply their
* return code — everything else, including the ledger period and branch scope, is
* common.
*/
export abstract class GlMappingHost {
private dialog = inject(MatDialog);
private branchScope = inject(BranchScopeService);
/** e.g. 'R070'. Used to namespace this return's mappings. */
abstract readonly glReturnCode: string;
/** Ledger period the mapped figures are drawn from. */
abstract get glRange(): GlDateRange;
/** Whether the per-line mapping controls are visible. */
glMapMode = false;
/** '' = all branches consolidated. Seeded from the login's own scope. */
glBranchCode = this.branchScope.defaultBranchCode;
readonly glBranches = GL_BRANCHES;
/** Head Office may derive figures from any branch; a branch login cannot. */
get canSelectGlBranch(): boolean {
return this.branchScope.canSelectBranch;
}
get glScopeName(): string {
return this.branchScope.scopeLabel(this.glBranchCode);
}
/** Branch actually used for every ledger lookup, enforced rather than trusted. */
private get effectiveGlBranch(): string {
return this.branchScope.enforce(this.glBranchCode);
}
toggleGlMapMode(): void {
this.glMapMode = !this.glMapMode;
}
glLineKey(label: string): string {
return lineKeyFor(label);
}
glCodesFor(label: string): string[] {
return getMapping(this.glReturnCode, this.glLineKey(label));
}
isGlMapped(label: string): boolean {
return this.glCodesFor(label).length > 0;
}
get glMappedCount(): number {
return mappedLineCount(this.glReturnCode);
}
/** Ledger figure for a line, or null when it has no mapping. */
glValue(label: string): number | null {
return mappedValue(this.glReturnCode, this.glLineKey(label), this.glRange, this.effectiveGlBranch);
}
/**
* The number to print for a line: the ledger value when mapped, otherwise the
* value filed on the return.
*/
effectiveValue(label: string, filed: number | null): number | null {
const mapped = this.glValue(label);
return mapped === null ? filed : mapped;
}
/** True when a mapped line disagrees with the figure originally filed. */
glDiffers(label: string, filed: number | null): boolean {
const mapped = this.glValue(label);
if (mapped === null || filed === null) {
return false;
}
return Math.abs(mapped - filed) >= 0.005;
}
removeGlFromLine(label: string, code: string): void {
removeGl(this.glReturnCode, this.glLineKey(label), code);
}
clearAllGlMappings(): void {
clearReturnMappings(this.glReturnCode);
}
/** Opens the chart-of-accounts picker for one return line. */
openGlPicker(label: string, filed: number | null): void {
const key = this.glLineKey(label);
const data: GlMappingDialogData = {
returnCode: this.glReturnCode,
lineLabel: label,
filedValue: filed,
selected: getMapping(this.glReturnCode, key),
range: this.glRange,
branchCode: this.effectiveGlBranch
};
this.dialog
.open(GlMappingDialogComponent, {
width: '92vw',
maxWidth: '980px',
maxHeight: '92vh',
autoFocus: false,
data
})
.afterClosed()
.subscribe((codes: string[] | undefined) => {
if (codes) {
setMapping(this.glReturnCode, key, codes);
}
});
}
}
+170
View File
@@ -0,0 +1,170 @@
/**
* 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/.
*/
/**
* Maps RBI return line items to General Ledger accounts.
*
* A return line can be pinned to one or more GL codes; the line's figure is then
* derived from the ledger instead of being a static number, so the filed return and
* the books cannot drift apart silently. Mappings are keyed by return code plus a
* stable line key and persisted locally.
*
* The OSS returns are denominated in thousands while the ledger is in rupees, so
* every derived figure is scaled on the way out.
*/
import { GL_ACCOUNTS, GlAccount } from 'app/gl-reports/gl-reports-data';
import { GlDateRange, glValueForCodes } from 'app/gl-reports/gl-reports.compute';
const STORAGE_KEY = 'rbi_return_gl_mappings';
/** Returns are reported in thousands of rupees. */
export const RETURN_SCALE = 1000;
export type GlMappingStore = Record<string, string[]>;
function mapKey(returnCode: string, lineKey: string): string {
return `${returnCode}::${lineKey}`;
}
/**
* The store is read from template getters on every change-detection pass, so it is
* held in memory and only re-parsed when a write invalidates it. Parsing JSON a few
* hundred times per click is otherwise a measurable cost on the larger returns.
*/
let storeCache: GlMappingStore | null = null;
function readStore(): GlMappingStore {
if (storeCache) {
return storeCache;
}
try {
storeCache = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') as GlMappingStore;
} catch {
storeCache = {};
}
return storeCache;
}
function writeStore(store: GlMappingStore): void {
storeCache = store;
localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
}
/** GL codes currently pinned to a return line. */
export function getMapping(returnCode: string, lineKey: string): string[] {
return readStore()[mapKey(returnCode, lineKey)] ?? [];
}
export function setMapping(returnCode: string, lineKey: string, codes: string[]): void {
const store = readStore();
const key = mapKey(returnCode, lineKey);
if (codes.length) {
store[key] = codes;
} else {
delete store[key];
}
writeStore(store);
}
export function addGl(returnCode: string, lineKey: string, code: string): void {
const current = getMapping(returnCode, lineKey);
if (!current.includes(code)) {
setMapping(returnCode, lineKey, [
...current,
code
]);
}
}
export function removeGl(returnCode: string, lineKey: string, code: string): void {
setMapping(
returnCode,
lineKey,
getMapping(returnCode, lineKey).filter((c) => c !== code)
);
}
/** Every line in a return that currently has a mapping. */
export function mappedLineCount(returnCode: string): number {
const prefix = `${returnCode}::`;
return Object.keys(readStore()).filter((k) => k.startsWith(prefix)).length;
}
export function clearReturnMappings(returnCode: string): void {
const store = readStore();
const prefix = `${returnCode}::`;
for (const k of Object.keys(store)) {
if (k.startsWith(prefix)) {
delete store[k];
}
}
writeStore(store);
}
/**
* Ledger value for a mapped line, expressed in the return's own scale (thousands).
* Returns null when the line has no mapping, so callers can fall back to the filed figure.
*/
export function mappedValue(returnCode: string, lineKey: string, range: GlDateRange, branchCode = ''): number | null {
const codes = getMapping(returnCode, lineKey);
if (!codes.length) {
return null;
}
return glValueForCodes(codes, range, branchCode) / RETURN_SCALE;
}
/** A line key that stays stable across reorderings — derived from the label itself. */
export function lineKeyFor(label: string): string {
return label
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 80);
}
export interface GlMappingEntry {
returnCode: string;
lineKey: string;
codes: string[];
}
/** Every mapping across all returns — drives the GL Mapping for Report listing. */
export function allMappings(): GlMappingEntry[] {
return Object.entries(readStore()).map(
([
key,
codes
]) => {
const [
returnCode,
lineKey
] = key.split('::');
return { returnCode, lineKey, codes };
}
);
}
/** Human-readable name for a return code. */
export function returnName(code: string): string {
const names: Record<string, string> = {
R070: 'OSS 1 — Return on Assets and Liabilities',
R071: 'OSS 2 — Return on Earnings',
R323: 'OSS 3 — Return on Asset Quality'
};
return names[code] ?? code;
}
export function glAccountsForPicker(): GlAccount[] {
return GL_ACCOUNTS;
}
export function glLabel(code: string): string {
const a = GL_ACCOUNTS.find((x) => x.code === code);
return a ? `${a.code} — ${a.name}` : code;
}
+134
View File
@@ -0,0 +1,134 @@
/**
* 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/.
*/
/**
* Shapes common to every RBI CIMS off-site surveillance return (OSS 1..7).
* Kept free of any ExcelJS import so data files stay dependency-light.
*/
/** One labelled row of a return. */
export interface ReturnLine {
label: string;
/** 0 = numbered head, 1 = roman sub-item, 2 = a/b/c leaf. Drives indentation. */
level: number;
/** null renders as an empty cell, never as 0 — RBI treats blank and zero differently. */
value: number | null;
/** Derived/aggregate row — shaded and bold, like the blue cells in the workbook. */
total?: boolean;
/** Row is a headcount (depositors / borrowers / employees), not a rupee amount. */
count?: boolean;
}
/**
* One row of a multi-column statement. Returns from OSS 3 onwards are matrices
* (risk category x asset class) rather than the single-value lists of OSS 1/2.
*/
export interface MatrixRow {
label: string;
/** 0 = numbered head, 1..n = progressively indented sub-items. */
level: number;
/** One entry per data column; null renders blank, never 0. */
values: (number | null)[];
/** Derived/aggregate row — shaded and bold. */
total?: boolean;
/** Caption-only row that spans the table with no figures of its own. */
header?: boolean;
/** Render values as percentages rather than amounts. */
percent?: boolean;
}
/** A labelled statement made of a header band plus matrix rows. */
export interface MatrixTable {
title: string;
subtitle?: string;
/** Column captions excluding the leading label column. */
columns: string[];
rows: MatrixRow[];
notes?: string[];
unit?: string;
}
/** A signatory block as it appears on the Signatory sheet of every OSS return. */
export interface ReturnSignatory {
heading: string;
signature: string;
name: string;
designation: string;
email: string;
telephoneOffice: string;
telephoneResidence: string;
place: string;
date: string;
}
/** A `label -> value` row on the General Information sheet. */
export interface ReturnGeneralInfoRow {
label: string;
value: string;
}
/** Field order shared by the Signatory sheet of every OSS return. */
export const SIGNATORY_FIELDS: { key: keyof ReturnSignatory; caption: string }[] = [
{ key: 'signature', caption: 'Signature' },
{ key: 'name', caption: 'Name' },
{ key: 'designation', caption: 'Designation' },
{ key: 'email', caption: 'e-mail ID' },
{ key: 'telephoneOffice', caption: 'Tele. No. (O)' },
{ key: 'telephoneResidence', caption: 'Tele. No. (R)' },
{ key: 'place', caption: 'Place' },
{ key: 'date', caption: 'Date' }
];
/**
* Identity of the reporting bank, shared by every OSS return so the General
* Information sheets can never drift apart between OSS 1 and OSS 2.
*/
export const OSS_BANK = {
name: 'CBSNEXORA BANK LTD., HEAD OFFICE',
bankCode: '91007',
roName: 'MUMBAI',
institutionType: 'Urban Cooperative Banks',
reportingFrequency: 'Quarterly',
quarterEndDate: '30-Jun-2026',
auditFlag: 'Un-Audited',
dateOfAudit: '',
returnVersion: 'V1.0',
startDate: '01-Apr-2026',
/** OSS 3 prints dates as DD-MM-YYYY rather than DD-Mon-YYYY. */
startDateNumeric: '01-04-2026',
quarterEndDateNumeric: '30-06-2026'
};
/**
* Signatories for the demo bank. The `.example` domain is reserved for
* documentation, so these addresses can never reach a real inbox.
*/
export const OSS_SIGNATORIES: ReturnSignatory[] = [
{
heading: 'Authorised Reporting Official',
signature: 'ROHAN DESHPANDE',
name: 'ROHAN DESHPANDE',
designation: 'MANAGER — REGULATORY REPORTING',
email: 'rohan.deshpande@cbsnexorabank.example',
telephoneOffice: '022-40015520',
telephoneResidence: '022-40015588',
place: 'MUMBAI',
date: '14/07/2026'
},
{
heading: 'Countersigned By',
signature: 'ANJALI MENON',
name: 'ANJALI MENON',
designation: 'CHIEF EXECUTIVE OFFICER',
email: 'ceo@cbsnexorabank.example',
telephoneOffice: '022-40015500',
telephoneResidence: '022-40015501',
place: 'MUMBAI',
date: '14/07/2026'
}
];
+569
View File
@@ -0,0 +1,569 @@
/**
* 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 {
/* Mirrors the RBI CIMS workbook palette so the page and the export read alike. */
--o-navy: #002f5f;
--o-blue: #1d4ed8;
--o-blue-l: #eff6ff;
--o-derived: #cfe2f8;
--o-green: #15803d;
--o-green-l: #dcfce7;
--o-amber-l: #ffc;
--o-amber-d: #92400e;
--o-red: #c0392b;
--o-red-l: #fdecea;
--o-g50: #f8fafc;
--o-g100: #f1f5f9;
--o-g200: #e2e8f0;
--o-g300: #cbd5e1;
--o-g500: #64748b;
--o-g700: #334155;
--o-g900: #0f172a;
display: block;
font-family:
'DM Sans',
system-ui,
-apple-system,
sans-serif;
background: var(--o-g50);
color: var(--o-g900);
font-size: 14px;
line-height: 1.5;
}
.oss-root {
padding: 18px 22px;
max-width: 1500px;
margin: 0 auto;
}
/* HEADER */
.oss-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.oss-head-left {
display: flex;
align-items: center;
gap: 14px;
}
.oss-head-ic {
font-size: 28px;
color: var(--o-navy);
}
.oss-head-t {
margin: 0;
font-size: 18px;
font-weight: 700;
}
.oss-head-s {
font-size: 12px;
color: var(--o-g500);
}
.oss-head-right {
display: flex;
align-items: center;
gap: 12px;
}
.oss-audit-pill {
padding: 4px 12px;
border-radius: 999px;
background: var(--o-amber-l);
color: var(--o-amber-d);
border: 1px solid #fcd34d;
font-size: 12px;
font-weight: 700;
}
/* BUTTONS */
.oss-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 16px;
border: 1px solid var(--o-g300);
background: #fff;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: var(--o-g700);
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.oss-btn-xls {
background: #16a34a;
border-color: #16a34a;
color: #fff;
&:hover:not(:disabled) {
background: #15803d;
border-color: #15803d;
}
}
/* BALANCE CHECK */
.oss-balance {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
border-radius: 10px;
background: var(--o-green-l);
border: 1px solid #86efac;
color: #14532d;
font-size: 13px;
margin-bottom: 14px;
&--bad {
background: var(--o-red-l);
border-color: #fca5a5;
color: #7f1d1d;
}
}
/* TABS */
.oss-tabs {
display: flex;
gap: 4px;
background: #fff;
border: 1px solid var(--o-g200);
border-radius: 10px;
padding: 4px;
margin-bottom: 16px;
overflow-x: auto;
}
.oss-tab {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 14px;
border: none;
background: transparent;
font-size: 13px;
font-weight: 500;
color: var(--o-g500);
cursor: pointer;
border-radius: 7px;
white-space: nowrap;
transition: all 0.15s;
font-family: inherit;
&:hover {
color: var(--o-g900);
}
&.active {
background: var(--o-navy);
color: #fff;
box-shadow: 0 2px 6px rgb(0 47 95 / 25%);
}
}
.oss-section {
display: flex;
flex-direction: column;
gap: 16px;
}
/* Full-width banner, matching the dark blue header band in the workbook. */
.oss-sheet-banner {
background: var(--o-navy);
color: #fff;
padding: 10px 18px;
border-radius: 8px;
font-size: 14px;
font-weight: 700;
text-align: center;
letter-spacing: 0.02em;
}
/* TABLES */
.oss-tbl-wrap {
background: #fff;
border: 1px solid var(--o-g200);
border-radius: 10px;
overflow: auto;
box-shadow: 0 1px 3px rgb(0 0 0 / 4%);
}
.oss-tbl-h {
background: var(--o-blue-l);
color: var(--o-navy);
padding: 10px 16px;
font-size: 12px;
font-weight: 700;
border-bottom: 1px solid var(--o-g200);
}
.oss-tbl {
width: 100%;
border-collapse: collapse;
th {
text-align: left;
padding: 9px 12px;
font-size: 11px;
font-weight: 700;
color: var(--o-g700);
background: var(--o-g100);
border-bottom: 1px solid var(--o-g200);
border-right: 1px solid var(--o-g200);
}
td {
padding: 8px 12px;
border-bottom: 1px solid var(--o-g100);
border-right: 1px solid var(--o-g100);
font-size: 13px;
color: var(--o-g700);
}
tr:last-child td {
border-bottom: none;
}
tbody tr:hover td {
background: var(--o-g50);
}
}
/* Key/value layout used by General Information and Signatory. */
.oss-tbl--kv {
th {
width: 40%;
background: #d9d9d9;
color: var(--o-g900);
}
td.filled {
background: var(--o-derived);
}
td.audit {
background: var(--o-amber-l);
font-weight: 700;
}
}
/* Derived/aggregate rows — the blue cells in the original sheet. */
.total-row td {
font-weight: 700;
color: var(--o-g900);
}
td.derived {
background: var(--o-derived);
font-weight: 700;
color: var(--o-navy);
}
/* Indentation matching the return's numbered / roman / lettered hierarchy. */
.lvl-0 {
font-weight: 600;
}
.lvl-1 {
padding-left: 30px !important;
}
.lvl-2 {
padding-left: 52px !important;
}
.lvl-3 {
padding-left: 74px !important;
}
/* Caption-only row spanning a statement (e.g. "PRIORITY SECTOR"). */
.section-row td {
background: var(--o-g100);
font-weight: 700;
color: var(--o-g900);
}
/* Wide matrices keep their label column readable while the figures scroll. */
.oss-col-label {
min-width: 320px;
}
.oss-tbl-unit {
float: right;
font-weight: 600;
color: var(--o-g500);
}
.oss-tbl-sub {
padding: 8px 16px;
font-size: 11px;
font-weight: 600;
color: var(--o-g700);
background: var(--o-g100);
border-bottom: 1px solid var(--o-g200);
}
.ta-c {
text-align: center;
}
.ta-r {
text-align: right;
}
.mono {
font-family: 'DM Mono', monospace;
font-size: 12px;
}
/* NOTES */
.oss-notes {
background: var(--o-g100);
border: 1px solid var(--o-g200);
border-radius: 8px;
padding: 10px 16px;
}
.oss-note-line {
font-size: 12px;
font-weight: 600;
color: var(--o-g700);
padding: 2px 0;
}
.oss-foot-note {
margin: -6px 2px 0;
font-size: 11px;
color: var(--o-g500);
}
/* ── General Ledger mapping ─────────────────────────────────────────────── */
.oss-gl-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
flex-wrap: wrap;
padding: 10px 16px;
border-radius: 10px;
background: #fff;
border: 1px solid var(--o-g200);
&.on {
background: var(--o-blue-l);
border-color: #93c5fd;
}
}
.oss-gl-bar-l {
display: flex;
align-items: center;
gap: 9px;
font-size: 13px;
color: var(--o-g700);
}
.oss-gl-bar-r {
display: flex;
align-items: center;
gap: 8px;
}
.oss-gl-count {
display: inline-block;
margin-left: 8px;
padding: 2px 9px;
border-radius: 999px;
background: var(--o-green-l);
color: var(--o-green);
font-size: 11px;
font-weight: 700;
}
.oss-gl-inp {
padding: 7px 11px;
border: 1px solid var(--o-g300);
border-radius: 8px;
font-size: 12.5px;
font-family: inherit;
background: #fff;
}
.oss-btn-clear {
color: var(--o-red);
border-color: var(--o-red);
}
.oss-gl-col {
min-width: 240px;
}
/* Read-only branch shown to a branch-level login in place of the selector. */
.oss-gl-scope {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border: 1px solid var(--o-g300);
border-radius: 8px;
background: var(--o-g100);
font-size: 12.5px;
font-weight: 600;
color: var(--o-g700);
}
.oss-gl-cell {
display: flex;
align-items: center;
gap: 5px;
flex-wrap: wrap;
}
.oss-gl-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 4px 2px 8px;
border-radius: 999px;
background: #e0f2fe;
color: #075985;
font-size: 11px;
font-weight: 600;
}
.oss-gl-chip-x {
border: none;
background: rgb(7 89 133 / 14%);
color: #075985;
width: 15px;
height: 15px;
border-radius: 50%;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 8px;
&:hover {
background: var(--o-red);
color: #fff;
}
}
.oss-gl-add {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 9px;
border: 1px dashed var(--o-g300);
background: #fff;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
color: var(--o-g500);
cursor: pointer;
font-family: inherit;
&:hover {
border-color: var(--o-navy);
color: var(--o-navy);
border-style: solid;
}
}
.oss-gl-warn {
font-size: 10.5px;
font-weight: 700;
color: var(--o-amber-d);
white-space: nowrap;
}
/* A figure sourced from the ledger rather than the filed return. */
td.gl-sourced {
background: #ecfdf5;
box-shadow: inset 3px 0 0 var(--o-green);
}
td.gl-diff {
background: #fffbeb;
box-shadow: inset 3px 0 0 #d97706;
}
/* Tie-up status pills (cross-return reconciliation checks). */
.report-pill {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
&.report-ok {
background: var(--o-green-l);
color: var(--o-green);
}
&.report-bad {
background: var(--o-red-l);
color: var(--o-red);
}
}
.oss-sign-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
/* TOAST */
.oss-toast {
position: fixed;
bottom: 32px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--o-navy);
color: #fff;
padding: 12px 22px;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
box-shadow: 0 6px 20px rgb(0 0 0 / 25%);
opacity: 0;
pointer-events: none;
transition: all 0.25s;
z-index: 1500;
&.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
@media (width <= 1000px) {
.oss-sign-grid {
grid-template-columns: 1fr;
}
}
+239
View File
@@ -0,0 +1,239 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import * as ExcelJS from 'exceljs';
import { MatrixTable, ReturnGeneralInfoRow, ReturnLine, ReturnSignatory, SIGNATORY_FIELDS } from './oss-return.model';
/** Palette lifted from the RBI CIMS workbooks so exports look like the originals. */
export const XL = {
banner: 'FF002F5F',
bannerText: 'FFFFFFFF',
headerGrey: 'FFC0C0C0',
derivedBlue: 'FF9CC3F5',
auditYellow: 'FFFFFFCC',
keyCell: 'FFD9D9D9',
border: 'FF000000'
};
const AMOUNT_FMT = '#,##0.00';
const COUNT_FMT = '#,##0';
export function solidFill(argb: string): ExcelJS.Fill {
return { type: 'pattern', pattern: 'solid', fgColor: { argb } };
}
/** Dark-blue full-width banner that heads every sheet in the RBI templates. */
export function addBanner(sheet: ExcelJS.Worksheet, title: string, lastCol: string): void {
const row = sheet.addRow([title]);
sheet.mergeCells(`A${row.number}:${lastCol}${row.number}`);
row.height = 26;
const cell = row.getCell(1);
cell.fill = solidFill(XL.banner);
cell.font = { bold: true, size: 13, color: { argb: XL.bannerText } };
cell.alignment = { vertical: 'middle', horizontal: 'center' };
sheet.addRow([]);
}
export function boxAllCells(sheet: ExcelJS.Worksheet, from: number, to: number, lastColIndex: number): void {
for (let r = from; r <= to; r++) {
for (let c = 1; c <= lastColIndex; c++) {
sheet.getRow(r).getCell(c).border = {
top: { style: 'thin', color: { argb: XL.border } },
left: { style: 'thin', color: { argb: XL.border } },
bottom: { style: 'thin', color: { argb: XL.border } },
right: { style: 'thin', color: { argb: XL.border } }
};
}
}
}
export function addSectionHeader(sheet: ExcelJS.Worksheet, label: string, unitLabel?: string): void {
const row = sheet.addRow([
label,
unitLabel ?? ''
]);
row.font = { bold: true };
row.eachCell((cell) => (cell.fill = solidFill(XL.headerGrey)));
row.getCell(2).alignment = { horizontal: 'right' };
}
/** Renders a labelled block; indents by level and shades derived totals. */
export function addLineRows(sheet: ExcelJS.Worksheet, lines: ReturnLine[]): void {
for (const line of lines) {
const row = sheet.addRow([
line.label,
line.value
]);
const labelCell = row.getCell(1);
const valueCell = row.getCell(2);
labelCell.alignment = { wrapText: true, vertical: 'middle', indent: line.level };
if (line.level === 0) {
labelCell.font = { bold: true };
}
valueCell.numFmt = line.count ? COUNT_FMT : AMOUNT_FMT;
valueCell.alignment = { horizontal: 'right' };
if (line.total) {
valueCell.fill = solidFill(XL.derivedBlue);
valueCell.font = { bold: true };
}
}
}
/**
* Writes a multi-column statement: title band, column header row, then the matrix
* rows. Returns the row number after the block so callers can keep stacking.
*/
export function addMatrixTable(sheet: ExcelJS.Worksheet, table: MatrixTable): void {
const colCount = table.columns.length + 1;
const start = sheet.rowCount + 1;
const titleRow = sheet.addRow([
table.title,
...(table.unit ? [table.unit] : [])
]);
if (!table.unit) {
sheet.mergeCells(titleRow.number, 1, titleRow.number, colCount);
}
titleRow.font = { bold: true };
for (let c = 1; c <= colCount; c++) {
titleRow.getCell(c).fill = solidFill(XL.headerGrey);
}
if (table.subtitle) {
const sub = sheet.addRow([table.subtitle]);
sheet.mergeCells(sub.number, 1, sub.number, colCount);
sub.font = { bold: true, size: 10 };
sub.alignment = { wrapText: true };
sub.getCell(1).fill = solidFill(XL.headerGrey);
}
const head = sheet.addRow([
'Particulars',
...table.columns
]);
head.font = { bold: true };
head.alignment = { wrapText: true, horizontal: 'center', vertical: 'middle' };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const row of table.rows) {
const excelRow = sheet.addRow([
row.label,
...row.values
]);
const labelCell = excelRow.getCell(1);
labelCell.alignment = { wrapText: true, vertical: 'middle', indent: row.level };
if (row.level === 0 || row.header) {
labelCell.font = { bold: true };
}
if (row.header) {
labelCell.fill = solidFill(XL.headerGrey);
continue;
}
for (let c = 2; c <= colCount; c++) {
const cell = excelRow.getCell(c);
cell.numFmt = row.percent ? '0.00"%"' : AMOUNT_FMT;
cell.alignment = { horizontal: 'right' };
if (row.total) {
cell.fill = solidFill(XL.derivedBlue);
cell.font = { bold: true };
}
}
}
boxAllCells(sheet, start, sheet.rowCount, colCount);
for (const note of table.notes ?? []) {
const noteRow = sheet.addRow([note]);
sheet.mergeCells(noteRow.number, 1, noteRow.number, colCount);
noteRow.font = { bold: true, size: 9 };
noteRow.alignment = { wrapText: true };
noteRow.getCell(1).fill = solidFill(XL.headerGrey);
}
sheet.addRow([]);
}
/** The General Information sheet is identical in shape across every OSS return. */
export function buildGeneralInfoSheet(wb: ExcelJS.Workbook, rows: ReturnGeneralInfoRow[]): void {
const sheet = wb.addWorksheet('General Information');
sheet.columns = [
{ width: 26 },
{ width: 58 }];
addBanner(sheet, 'General Information', 'B');
const first = sheet.rowCount + 1;
for (const item of rows) {
const row = sheet.addRow([
item.label,
item.value
]);
row.getCell(1).fill = solidFill(XL.keyCell);
// The workbook tints the audit flag yellow and leaves unfilled cells grey.
const fill = item.label === 'Audit Flag' ? XL.auditYellow : item.value ? XL.derivedBlue : XL.headerGrey;
row.getCell(2).fill = solidFill(fill);
}
boxAllCells(sheet, first, sheet.rowCount, 2);
}
/** The Signatory sheet is likewise identical across returns. */
export function buildSignatorySheet(
wb: ExcelJS.Workbook,
signatories: ReturnSignatory[],
sheetName = 'Signatory'
): void {
const sheet = wb.addWorksheet(sheetName);
sheet.columns = [
{ width: 44 },
{ width: 24 }];
addBanner(sheet, sheetName, 'B');
for (const person of signatories) {
const start = sheet.rowCount + 1;
const head = sheet.addRow([
person.heading,
''
]);
head.font = { bold: true };
head.alignment = { horizontal: 'center' };
head.getCell(1).fill = solidFill(XL.keyCell);
for (const f of SIGNATORY_FIELDS) {
const row = sheet.addRow([
person[f.key],
f.caption
]);
row.getCell(2).font = { bold: true };
row.getCell(2).alignment = { horizontal: 'center' };
row.getCell(2).fill = solidFill(XL.keyCell);
}
boxAllCells(sheet, start, sheet.rowCount, 2);
sheet.addRow([]);
}
}
/** Serialises the workbook and triggers a browser download. */
export async function downloadWorkbook(wb: ExcelJS.Workbook, filename: string): Promise<void> {
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export function newWorkbook(): ExcelJS.Workbook {
const wb = new ExcelJS.Workbook();
wb.creator = 'CBSNexora';
wb.created = new Date();
return wb;
}
@@ -0,0 +1,511 @@
/**
* 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/.
*/
/**
* OSS 1 — Return on Assets and Liabilities (RAL), return code R070.
*
* Mirrors the RBI CIMS "Form RAL_CIMS Template1 OSS 1" workbook sheet-for-sheet:
* General Information / Assets & Liabilities / Annexure 1 / Annexure 2 / Signatory
*
* All monetary values are Rs in Thousands, exactly as the return prescribes.
* A `null` value means the source return leaves that cell blank — blank and zero
* are different to RBI, so they must not be collapsed into one another.
*/
import { OSS_BANK, OSS_SIGNATORIES, ReturnGeneralInfoRow, ReturnLine, ReturnSignatory } from '../oss-return.model';
/** One labelled row of the return. */
export type RalLine = ReturnLine;
export interface RalTopDeposit {
srNo: number;
institution: string;
amount: number | null;
rateOfInterest: number | null;
dateOfReceipt: string;
days: number | null;
months: number | null;
years: number | null;
}
export interface RalAgeingRow {
period: string;
noOfDebitEntries: number | null;
amtOfDebitEntries: number | null;
noOfCreditEntries: number | null;
amtOfCreditEntries: number | null;
}
export interface RalUnsecuredGuarantee {
srNo: number;
borrower: string;
beneficiary: string;
amountOutstanding: number | null;
}
export interface RalSundryDebtor {
srNo: number;
dueFromName: string;
amountDue: number | null;
provisionMade: number | null;
}
export type RalSignatory = ReturnSignatory;
/**
* Sheet 1 — General Information.
* Return Name / Return Code are fixed by RBI; everything else is this bank's own
* identity and the quarter being reported.
*/
export const RAL_GENERAL_INFO: ReturnGeneralInfoRow[] = [
{ label: 'Return Name', value: 'Return on Assets and Liabilities (RAL)' },
{ label: 'Return Code', value: 'R070' },
{ label: 'Bank Name', value: OSS_BANK.name },
{ label: 'Bank Code', value: OSS_BANK.bankCode },
{ label: 'RO Name', value: OSS_BANK.roName },
{ label: 'Reporting Frequency', value: OSS_BANK.reportingFrequency },
{ label: 'Quarter End Date', value: OSS_BANK.quarterEndDate },
{ label: 'Audit Flag', value: OSS_BANK.auditFlag },
{ label: 'Date of Audit', value: OSS_BANK.dateOfAudit },
{ label: 'Return Version', value: OSS_BANK.returnVersion },
{ label: 'Start Date', value: OSS_BANK.startDate }
];
/** Sheet 2 — Assets & Liabilities :: Liabilities */
export const RAL_LIABILITIES: RalLine[] = [
{ label: '1. Paid-up Capital', level: 0, value: 27278, total: true },
{ label: 'i. Individuals', level: 1, value: 27278 },
{ label: 'ii. State Government', level: 1, value: null },
{ label: 'iii. Others', level: 1, value: null },
{ label: '2. Reserve Fund and other Reserves', level: 0, value: 60863, total: true },
{ label: 'i. Statutory Reserve Fund', level: 1, value: 31149 },
{ label: 'ii. Building Fund', level: 1, value: 13642 },
{ label: 'iii. Dividend Equalization Fund', level: 1, value: 36 },
{ label: 'iv. Revaluation Reserve', level: 1, value: null },
{ label: 'v. Provision for Standard Assets', level: 1, value: 3750 },
{ label: 'vi. General Provisions (other than provision for standard advances)', level: 1, value: 282 },
{
label: 'vii. Other Funds and Reserves not in the nature of outside liability-(Refer Annexure I)',
level: 1,
value: 12004
},
{ label: '3. Profit (+) / Loss (-)', level: 0, value: 10397, total: true },
{ label: 'i. Surplus Unallocated and Carried over from Previous years', level: 1, value: 5592 },
{ label: 'ii. Accumulated Losses of Previous years', level: 1, value: null },
{ label: 'iii. Operating Surplus in Current year', level: 1, value: 4805 },
{ label: 'iv. Operating Deficit in Current year', level: 1, value: null },
{ label: '4. Total Capital And Reserves', level: 0, value: 98538, total: true },
{ label: '5. Subordinated Debt (Tier II Capital) #', level: 0, value: null },
{ label: '6. Deposits and other Accounts', level: 0, value: 634309, total: true },
{ label: 'Customer Deposits -', level: 1, value: 634309, total: true },
{ label: 'Total No of Depositors', level: 1, value: 8156, total: true, count: true },
{ label: 'A. Resident Deposits', level: 1, value: 634309, total: true },
{
label:
'i. Term Deposits (including recurring deposits, cash certificates, staff provident funds,security deposits and matured term deposits)',
level: 2,
value: 406601
},
{ label: 'No. of Term Depositors', level: 2, value: 3462, count: true },
{ label: 'ii. Savings Bank Deposits', level: 2, value: 208116 },
{ label: 'No of Savings Depositors', level: 2, value: 4455, count: true },
{ label: 'iii. Current Deposits', level: 2, value: 19592 },
{ label: 'No of Current Depositors', level: 2, value: 239, count: true },
{ label: 'B. Non Resident Deposits', level: 1, value: 0, total: true },
{ label: '1. NRI Deposits (Other than deposits in NRO Accounts)', level: 2, value: 0, total: true },
{ label: 'i. FCNR Deposits', level: 2, value: null },
{ label: 'No of FCNR Depositors', level: 2, value: null, count: true },
{ label: 'ii. NRE Deposits', level: 2, value: null },
{ label: 'No of NRE Depositors', level: 2, value: null, count: true },
{ label: '2. NRO Deposits', level: 2, value: null, total: true },
{ label: 'No of NRO Depositors', level: 2, value: null, count: true },
{ label: 'C. Deposit of Banks', level: 1, value: 0, total: true },
{ label: 'i. Commercial Banks', level: 2, value: null },
{ label: 'ii. Scheduled Cooperative Banks', level: 2, value: null },
{ label: 'iii. Other Cooperative Banks', level: 2, value: null },
{ label: '7. Borrowings from', level: 0, value: 0, total: true },
{ label: 'i. RBI / NABARD / SIDBI / NHB', level: 1, value: null },
{ label: 'ii. State/Central Coop. Bank of the State/ District', level: 1, value: 0, total: true },
{ label: 'a. Short-term loans', level: 2, value: null },
{ label: 'b. Medium-term loans', level: 2, value: null },
{ label: 'c. Cash credits and overdrafts', level: 2, value: null },
{ label: 'iii. Other banks (exclude borrowings from call money market)', level: 1, value: null },
{ label: 'iv. Call money market', level: 1, value: null },
{ label: 'v. Long Term Deposits', level: 1, value: null },
{ label: 'vi. Others', level: 1, value: null },
{ label: '8. Bills for collection being bills receivable as per contra', level: 0, value: null },
{ label: '9. Branch Adjustments *', level: 0, value: null },
{ label: '10. Overdue Interest Reserve', level: 0, value: 32096 },
{ label: '11. Interest Payable on', level: 0, value: 731, total: true },
{ label: 'i. Deposits', level: 1, value: 731 },
{ label: 'ii. Borrowings', level: 1, value: null },
{
label: '12. Risk Provisions Other than netted off assets (BDDR, Spl BDDR, Depreciation on fixed assets etc.,)',
level: 0,
value: 39440,
total: true
},
{ label: 'i. For investments (Investment Depreciation Reserve)', level: 1, value: 3750 },
{ label: 'ii. BDDR, Spl BDDR', level: 1, value: 24341 },
{ label: 'iii. Depreciation on fixed assets', level: 1, value: null },
{ label: 'iv. For other impaired assets', level: 1, value: 11349 },
{ label: 'v. For contingent / non-funded exposures', level: 1, value: null },
{ label: '13. Other Liabilities', level: 0, value: 5806, total: true },
{ label: 'i. Bills payable', level: 1, value: 291 },
{ label: 'ii. Unclaimed dividends', level: 1, value: null },
{ label: 'iii.Provident fund payable to Regional Provident Fund Commissioner', level: 1, value: null },
{ label: 'iv.Others', level: 1, value: 5515 },
{ label: '14. Total Outside Liabilities', level: 0, value: 712382, total: true },
{ label: '15. Total Liabilities', level: 0, value: 810920, total: true },
{ label: '16. Contingent Liabilities', level: 0, value: 2638, total: true },
{ label: 'i. Claims against the bank not acknowledged as debt', level: 1, value: null },
{ label: 'ii. Liabilities for partly paid investments', level: 1, value: null },
{ label: 'iii. Letter of Credit Documentary', level: 1, value: null },
{ label: 'iv. Guarantees - Financial', level: 1, value: null },
{ label: 'v. Guarantees- Others (Indemnity/ Tender/ Bid Bonds)', level: 1, value: null },
{ label: 'vi. Sale and Repurchase Agreement /Asset sales with recourse', level: 1, value: null },
{ label: 'vii. Liability on account of outstanding forward exchange contracts', level: 1, value: null },
{ label: 'viii. Acceptances, endorsements and other obligations', level: 1, value: null },
{ label: 'ix. Others', level: 1, value: 2638 }
];
/** Sheet 2 — Assets & Liabilities :: Assets */
export const RAL_ASSETS: RalLine[] = [
{ label: '1. Cash in Hand', level: 0, value: 9996 },
{ label: '2. I. Due from Banks', level: 0, value: 57600, total: true },
{ label: 'A. Balances with Banks', level: 1, value: 57600, total: true },
{
label: 'i. State/ Central Cooperative Bank of the State/District concerned in',
level: 2,
value: 32268,
total: true
},
{ label: 'a. Current', level: 2, value: 5980 },
{ label: 'b. Savings', level: 2, value: null },
{ label: 'c. Fixed', level: 2, value: 26288 },
{ label: 'ii. RBI :', level: 2, value: 0, total: true },
{ label: 'a. Current', level: 2, value: null },
{ label: 'b. Others', level: 2, value: null },
{ label: 'iii. SBI and other notified banks :', level: 2, value: 25332, total: true },
{ label: 'a. Current', level: 2, value: 13667 },
{ label: 'b. Savings', level: 2, value: null },
{ label: 'c. Fixed', level: 2, value: 11665 },
{ label: 'B. Loans and Advances to Banks (including bills)', level: 1, value: 0, total: true },
{ label: 'i. Call and Short Notice Money to Cooperative Banks', level: 2, value: null },
{ label: 'ii. Call and Short Notice Money to Other Banks', level: 2, value: null },
{ label: 'iii. Loans and Advances to Scheduled Cooperative Banks', level: 2, value: null },
{ label: 'iv.Loans and advances to Non scheduled Cooperative Banks', level: 2, value: null },
{ label: 'v. Loans and Advances to Other Banks', level: 2, value: null },
{
label: '2. II. Due from Other institutions (including post office / treasuries / others)',
level: 0,
value: 77353,
total: true
},
{ label: 'a. Current', level: 1, value: 9954 },
{ label: 'b. Fixed', level: 1, value: 67399 },
{ label: 'c. Others', level: 1, value: null },
{ label: '3. Investments', level: 0, value: 234297, total: true },
{
label: 'A. SLR Investments in Government and Other Approved Central and State Govt Securities',
level: 1,
value: 211734,
total: true
},
{ label: 'i. GOI T. Bills', level: 2, value: null },
{ label: 'ii. GOI (dated) Securities', level: 2, value: 63839 },
{ label: 'iii. State Government Securities', level: 2, value: 147895 },
{
label: 'iv. Other Trustee/approved securities including Land Development Bank debentures',
level: 2,
value: null
},
{ label: 'B. Non - SLR Investments', level: 1, value: 22563, total: true },
{ label: 'B1. Other Debt Securities', level: 2, value: 0, total: true },
{ label: 'i. Other State Government Securities', level: 2, value: null },
{ label: 'ii. Bonds of PSUs / All India Financial Institutions', level: 2, value: null },
{ label: 'B2. Equities / Units of Mutual Funds', level: 2, value: 63, total: true },
{ label: 'i. Shares in Cooperative Institutions', level: 2, value: 63 },
{ label: 'ii. Shares of All India Financial Institutions', level: 2, value: null },
{ label: 'iii. Units of Mutual Funds (UTI,etc)', level: 2, value: null },
{ label: 'B3. Investment in Subsidiaries / Equity Participations', level: 2, value: null },
{ label: 'B4. Other Investments', level: 2, value: 22500 },
{ label: '4. Loans and Advances', level: 0, value: 391288, total: true },
{ label: 'A. Gross Loans and Advances: (Other than banks)', level: 1, value: 391288, total: true },
{ label: 'Total No of Borrowers', level: 1, value: 363, total: true, count: true },
{ label: 'i. Short term loans', level: 2, value: 125082, total: true },
{ label: 'No of Borrowers', level: 2, value: 137, count: true },
{ label: 'a. Secured', level: 2, value: 125082 },
{ label: 'b. Unsecured', level: 2, value: null },
{ label: 'ii. Cash credits and overdrafts', level: 2, value: 166614, total: true },
{ label: 'No of Borrowers', level: 2, value: 140, count: true },
{ label: 'a. Secured', level: 2, value: 166614 },
{ label: 'b. Unsecured', level: 2, value: null },
{ label: 'iii. Medium - term loans', level: 2, value: 70236, total: true },
{ label: 'No of Borrowers', level: 2, value: 66, count: true },
{ label: 'a. Secured', level: 2, value: 70070 },
{ label: 'b. Unsecured', level: 2, value: 166 },
{ label: 'iv. Long - term loans', level: 2, value: 29356, total: true },
{ label: 'No of Borrowers', level: 2, value: 20, count: true },
{ label: 'a. Secured', level: 2, value: 29356 },
{ label: 'b. Unsecured', level: 2, value: null },
{ label: 'v. Bills/hundies purchased/discounted', level: 2, value: 0, total: true },
{ label: 'No of Borrowers', level: 2, value: null, count: true },
{ label: 'a. Documentary', level: 2, value: null },
{ label: 'b. Clean', level: 2, value: null },
{ label: 'B. Netting items on Loans and Advances', level: 1, value: 0, total: true },
{ label: 'i. Unrealised Interest in Suspense', level: 2, value: null },
{ label: 'ii. Credit Recoveries in Suspense', level: 2, value: null },
{ label: 'iii. Provisions for Credit Losses ( funded credits)', level: 2, value: null },
{ label: 'C. Loans and Advances (Net)', level: 1, value: 391288, total: true },
{ label: '5. Interest Receivable on', level: 0, value: 33541, total: true },
{ label: 'i. Investments', level: 1, value: 1445 },
{ label: 'ii.Loans and Advances', level: 1, value: 32096 },
{ label: 'Of 5 (ii) above, overdues', level: 1, value: 32096 },
{ label: '6. Bills Receivable being bills for collection as per contra', level: 0, value: null },
{ label: '7. Premises (less depreciation)', level: 0, value: 3185 },
{ label: '8. Furniture and Fixtures (less depreciation)', level: 0, value: 1763 },
{ label: '9. Branch Adjustments **', level: 0, value: null },
{ label: '10. Other Assets', level: 0, value: 1897, total: true },
{ label: 'i. Advance Tax Paid and TDS (net)', level: 1, value: null },
{ label: 'ii. Amounts due from RBI', level: 1, value: null },
{ label: 'iii. Sundry Debtors', level: 1, value: null },
{ label: 'iv. Intangible Assets', level: 1, value: null },
{ label: 'v. All Other', level: 1, value: 1897 },
{ label: '11. Non-banking Assets acquired in satisfaction of claims', level: 0, value: null },
{ label: '12. Total Assets (1 to 11)', level: 0, value: 810920, total: true }
];
/** Sheet 2 — Memo block */
export const RAL_MEMO: RalLine[] = [
{ label: '1.Capital Infusion during Apr to Date', level: 0, value: 0, total: true },
{ label: '1.1 Tier 1 Capital Raised during April to Date', level: 1, value: null },
{ label: '1.2 Tier 2 Capital Raised during April to Date', level: 1, value: null },
{ label: '2. Capital Instruments due for redemption in next twelve months', level: 0, value: null }
];
export const RAL_BALANCE_SHEET_NOTES: string[] = [
'Note : * To be furnished as per the books of the bank.',
'Note : ** Branch adjustments: Net position to be shown as an item of liability / assets, as the case may be.',
'Note: # Capital raised by way of RCPS / RNCPS / PNCPS to be reported under this item'
];
/** Sheet 3 — Annexure 1 :: 1. Break-up of Other Funds and Reserves */
export const RAL_OTHER_FUNDS: RalLine[] = [
{ label: 'i. Gratuity Fund', level: 1, value: null },
{ label: 'ii. Staff Welfare Fund', level: 1, value: 1310 },
{ label: 'iii. Silver Jubilee Fund', level: 1, value: null },
{ label: 'iv. Development Fund', level: 1, value: 36 },
{ label: 'v. Charity Fund', level: 1, value: 1718 },
{ label: 'vi. Any Other Fund/ Disclosed reserves', level: 1, value: 8940 },
{
label:
'vii. Undisclosed Reserves created out of PAT and included in B/S under heads like other liabilities and provisions',
level: 1,
value: null
},
{ label: 'Total', level: 0, value: 12004, total: true }
];
/** Sheet 3 — Annexure 1 :: 2. Top 20 deposits from other banks / institutions */
export const RAL_TOP_20_DEPOSITS: RalTopDeposit[] = Array.from(
{ length: 20 },
(_, i): RalTopDeposit => ({
srNo: i + 1,
institution: '',
amount: null,
rateOfInterest: null,
dateOfReceipt: '',
days: null,
months: null,
years: null
})
);
/** Column captions shared by the two age-wise analyses in Annexure 1. */
export const RAL_AGEING_HEADERS: string[] = [
'Time period',
'No. of debit entries',
'Amt. of debit entries',
'No. of credit entries',
'Amt. of credit entries'
];
/** Sheet 3 — Annexure 1 :: 3. Age-wise Inter Branch Adjustment Account */
export const RAL_INTER_BRANCH_AGEING: RalAgeingRow[] = [
{
period: '3 months to 6 months',
noOfDebitEntries: null,
amtOfDebitEntries: null,
noOfCreditEntries: null,
amtOfCreditEntries: null
},
{
period: 'Above 6 months to 1 year',
noOfDebitEntries: null,
amtOfDebitEntries: null,
noOfCreditEntries: null,
amtOfCreditEntries: null
},
{
period: 'Above 1 year to 2 years',
noOfDebitEntries: null,
amtOfDebitEntries: null,
noOfCreditEntries: null,
amtOfCreditEntries: null
},
{
period: 'Above 2 years',
noOfDebitEntries: null,
amtOfDebitEntries: null,
noOfCreditEntries: null,
amtOfCreditEntries: null
},
{ period: 'Total', noOfDebitEntries: 0, amtOfDebitEntries: 0, noOfCreditEntries: 0, amtOfCreditEntries: 0 }
];
/** Sheet 3 — Annexure 1 :: 4. Age-wise Inter Bank Reconciliation Account */
export const RAL_INTER_BANK_AGEING: RalAgeingRow[] = [
{
period: '3 months to 6 months',
noOfDebitEntries: null,
amtOfDebitEntries: null,
noOfCreditEntries: null,
amtOfCreditEntries: null
},
{
period: 'Above 6 months to 1 year',
noOfDebitEntries: null,
amtOfDebitEntries: null,
noOfCreditEntries: null,
amtOfCreditEntries: null
},
{
period: 'Above 1 year to 2 years',
noOfDebitEntries: null,
amtOfDebitEntries: null,
noOfCreditEntries: null,
amtOfCreditEntries: null
},
{
period: 'Above 2 years',
noOfDebitEntries: null,
amtOfDebitEntries: null,
noOfCreditEntries: null,
amtOfCreditEntries: null
},
{ period: 'Total', noOfDebitEntries: 0, amtOfDebitEntries: 0, noOfCreditEntries: 0, amtOfCreditEntries: 0 }
];
/** Sheet 3 — Annexure 1 :: 5. Unsecured guarantees issued by the bank */
export const RAL_UNSECURED_GUARANTEES: RalUnsecuredGuarantee[] = [
{ srNo: 1, borrower: '', beneficiary: '', amountOutstanding: null }];
export const RAL_UNSECURED_GUARANTEES_TOTAL = 0;
/** Sheet 4 — Annexure 2 :: Sundry Debtors ageing buckets */
export const RAL_SUNDRY_DEBTORS: { title: string; subtitle: string; rows: RalSundryDebtor[] }[] = [
{
title: 'Sundry Debtors Account -1',
subtitle: 'Long Outstanding Large Amounts - Outstanding for over 6 months but less than 1 year*',
rows: [{ srNo: 1, dueFromName: '', amountDue: null, provisionMade: null }]
},
{
title: 'Sundry Debtors Account -2',
subtitle: 'Long Outstanding Large Amounts - Outstanding for over 1 year but less than 3 years*',
rows: [{ srNo: 1, dueFromName: '', amountDue: null, provisionMade: null }]
},
{
title: 'Sundry Debtors Account -3',
subtitle: 'Long Outstanding Large Amounts - Outstanding for over 3 years*',
rows: [{ srNo: 1, dueFromName: '', amountDue: null, provisionMade: null }]
}
];
export const RAL_SUNDRY_DEBTORS_NOTE =
'* Large Amounts: Rs. 2 lakhs for banks having total assets of Rs. 100 lakhs and over Rs. 50,000 for other banks';
/** Sheet 4 — Annexure 2 :: Details of Depositors & Borrowers */
export const RAL_DEPOSITORS_BORROWERS: RalLine[] = [
{ label: '1.Total No of Depositors', level: 0, value: 8156, total: true, count: true },
{ label: 'Total Customer Deposits', level: 0, value: 634309, total: true },
{
label: 'a. i) Of which Total No. of Depositors with Deposits less than Rs. 1 Lakh',
level: 1,
value: 6568,
count: true
},
{ label: 'ii) Of which total Deposit Amount', level: 1, value: 109992 },
{
label: 'b. i) Of which Total No. of Depositors with Deposits Between Rs 1 Lakh to Rs. 2 Lakh',
level: 1,
value: 739,
count: true
},
{ label: 'ii) Of which total Deposit Amount', level: 1, value: 102475 },
{
label: 'c. i) Of which Total No. of Depositors with Deposits Between Rs. 2 Lakh to Rs 5 Lakh',
level: 1,
value: 580,
count: true
},
{ label: 'ii) Of which total Deposit Amount', level: 1, value: 176820 },
{ label: '2.Total No of Borrowers', level: 0, value: 363, total: true, count: true },
{ label: 'Total Loans and Advances (Other than Banks)', level: 0, value: 391288, total: true },
{
label: 'a. i) Of which Total No. of Borrowers with Loans and Advances less than Rs. 1 Lakh',
level: 1,
value: 55,
count: true
},
{ label: 'ii) Of which total Loans and Advances Amount', level: 1, value: 2061 },
{
label: 'b. i) Of which Total No. of Borrowers with Loans and Advances Between Rs 1 Lakh to Rs. 2 Lakh',
level: 1,
value: 18,
count: true
},
{ label: 'ii) Of which total Loans and Advances Amount', level: 1, value: 2837 },
{
label: 'c. i) Of which Total No. of Borrowers with Loans and Advances Between Rs. 2 Lakh to Rs 5 Lakh',
level: 1,
value: 55,
count: true
},
{ label: 'ii) Of which total Loans and Advances Amount', level: 1, value: 21260 }
];
/** Sheet 4 — Annexure 2 :: Complaints Received from public */
export const RAL_COMPLAINTS: RalLine[] = [
{ label: 'a) No. Of complaints outstanding at the beginning of the quarter', level: 1, value: 0, count: true },
{ label: 'b) No. Of complaints received during the quarter', level: 1, value: 0, count: true },
{ label: 'c) No. of complaints addressed during the quarter', level: 1, value: 0, count: true },
{ label: 'd) No. of complaints outstanding at the end of the quarter', level: 1, value: 0, count: true },
{ label: 'e) No. of complaints outstanding for over 2 quarters', level: 1, value: 0, count: true }
];
/** Sheet 5 — Signatory. Shared across returns so OSS 1 and OSS 2 always agree. */
export const RAL_SIGNATORIES: RalSignatory[] = OSS_SIGNATORIES;

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