Compare commits

..
36 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
nitesh 8c7d8287f9 Merge branch 'uate' into dev — sync branches to same level
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-04 11:35:08 +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
Anshul Rathore a982083b14 chore: stop tracking auto-generated .env.ts file 2026-07-31 15:08:35 +05:30
96 changed files with 27493 additions and 1268 deletions
+2 -2
View File
@@ -69,8 +69,8 @@
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
"maximumWarning": "13mb", "maximumWarning": "15mb",
"maximumError": "14mb" "maximumError": "17mb"
}, },
{ {
"type": "anyComponentStyle", "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>
</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"> <div class="gl-table-shell">
<mat-table [dataSource]="dataSource" matSort class="gl-mat-table"> <mat-table [dataSource]="dataSource" matSort class="gl-mat-table">
<!-- #ID --> <!-- #ID -->
@@ -692,6 +700,50 @@
</mat-cell> </mat-cell>
</ng-container> </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 --> <!-- Actions -->
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions">
<mat-header-cell *matHeaderCellDef class="gl-th"></mat-header-cell> <mat-header-cell *matHeaderCellDef class="gl-th"></mat-header-cell>
@@ -1328,6 +1328,17 @@
flex: 0 0 130px; 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 { ::ng-deep .mat-column-actions {
width: 50px; width: 50px;
min-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 ─────────────────────────────────────────────────────────────── // ── Empty state ───────────────────────────────────────────────────────────────
.gl-empty { .gl-empty {
display: flex; display: flex;
@@ -41,9 +41,20 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatTooltipModule } from '@angular/material/tooltip'; import { MatTooltipModule } from '@angular/material/tooltip';
import { MatIconModule } from '@angular/material/icon'; 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'; 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({ @Component({
selector: 'mifosx-search-journal-entry', selector: 'mifosx-search-journal-entry',
standalone: true, standalone: true,
@@ -87,6 +98,8 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private dateUtils = inject(Dates); private dateUtils = inject(Dates);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private amlService = inject(AmlService);
private dialog = inject(MatDialog);
// ── Misc ────────────────────────────────────────────────────────────────── // ── Misc ──────────────────────────────────────────────────────────────────
minDate = new Date(2000, 0, 1); minDate = new Date(2000, 0, 1);
@@ -129,6 +142,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
'manualEntry', 'manualEntry',
'createdByUserName', 'createdByUserName',
'submittedOnDate', 'submittedOnDate',
'amlMark',
'actions' 'actions'
]; ];
dayWiseColumns = [ dayWiseColumns = [
@@ -157,6 +171,14 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
dayWiseData: any[] = []; dayWiseData: any[] = [];
glAccountInfo: any = null; 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 ────────────────────────────────────────────────────────────── // ── UI state ──────────────────────────────────────────────────────────────
activeQuickFilter = ''; activeQuickFilter = '';
activeFiltersCount = 0; activeFiltersCount = 0;
@@ -212,6 +234,103 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
this.setFilteredOffices(); this.setFilteredOffices();
this.getJournalEntries(); this.getJournalEntries();
this.loadLedgerSummary(); 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() { ngOnDestroy() {
+22
View File
@@ -34,6 +34,28 @@ export class AmlService {
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes }); 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) ═══ // ═══ STR cases (Principal Officer) ═══
getStrCases(): Observable<any[]> { getStrCases(): Observable<any[]> {
+4
View File
@@ -68,6 +68,8 @@ import { GlReportsModule } from './gl-reports/gl-reports.module';
import { SftModule } from './sft/sft.module'; import { SftModule } from './sft/sft.module';
import { InvestmentModule } from './investment/investment.module'; import { InvestmentModule } from './investment/investment.module';
import { NpaModule } from './npa/npa.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 { DigitalModule } from './digital/digital.module';
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module'; import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
import { AccountsModule } from './accounts/accounts.module'; import { AccountsModule } from './accounts/accounts.module';
@@ -178,6 +180,8 @@ export function HttpLoaderFactory(http: HttpClient) {
SftModule, SftModule,
InvestmentModule, InvestmentModule,
NpaModule, NpaModule,
ReconciliationModule,
LoanOriginationModule,
DigitalModule, DigitalModule,
LetterMgmtModule, LetterMgmtModule,
AccountsModule, AccountsModule,
@@ -31,6 +31,9 @@
<button type="button" class="pg-btn" [class.pg-btn--active]="showDetails" (click)="toggleDetails()"> <button type="button" class="pg-btn" [class.pg-btn--active]="showDetails" (click)="toggleDetails()">
{{ showDetails ? 'Summary View' : 'Transaction Details' }} {{ showDetails ? 'Summary View' : 'Transaction Details' }}
</button> </button>
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
</div> </div>
</header> </header>
@@ -209,43 +212,61 @@
? 'Clearing' ? 'Clearing'
: 'Digital' : 'Digital'
}} }}
— Transaction Details — Transaction Details (Full Double Entry)
@if (glFilter !== null) { @if (glFilter !== null) {
· {{ glFilter }} · {{ glFilter }}
} }
</div> </div>
<table class="pg-table"> <table class="pg-table cctr-journal">
<thead> <thead>
<tr> <tr>
<th>Date</th> <th>Date</th>
<th>Voucher / Ref No</th> <th>Voucher / Ref No</th>
<th>Account No</th> <th>Dr / Cr</th>
<th>Account Name / Reference</th> <th>GL / Account</th>
<th>Particulars</th>
<th class="num col-out">Debit</th> <th class="num col-out">Debit</th>
<th class="num col-in">Credit</th> <th class="num col-in">Credit</th>
<th>Status</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (t of filteredTxns; track t.voucherNo) { @for (t of filteredTxns; track t.voucherNo) {
<tr> <tr class="cctr-journal-row cctr-journal-row--first">
<td>{{ asOnDate }}</td> <td>{{ asOnDate }}</td>
<td> <td>
<span class="pg-chip">{{ display(t.voucherNo) }}</span> <span class="pg-chip">{{ display(t.voucherNo) }}</span>
</td> </td>
<td>{{ display(t.accountNo) }}</td> <td><span class="cctr-drcr cctr-drcr--dr">Dr</span></td>
<td>{{ t.accountName }}</td> <td>
<td>{{ t.particulars }}</td>
<td class="num col-out">
@if (t.debit > 0) { @if (t.debit > 0) {
₹{{ t.debit | number: '1.2-2' }} <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>
<td class="num col-in"> <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) { @if (t.credit > 0) {
₹{{ t.credit | number: '1.2-2' }} <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>
<td class="num"></td>
<td class="num col-in">₹{{ legAmount(t) | number: '1.2-2' }}</td>
</tr> </tr>
} @empty { } @empty {
<tr> <tr>
@@ -255,9 +276,14 @@
</tbody> </tbody>
<tfoot> <tfoot>
<tr class="cctr-total-row"> <tr class="cctr-total-row">
<td colspan="5">Total</td> <td colspan="4">Total ({{ filteredTxns.length }} vouchers)</td>
<td class="num">₹{{ txnTotal(filteredTxns, 'debit') | number: '1.2-2' }}</td> <td class="num">
<td class="num">₹{{ txnTotal(filteredTxns, 'credit') | number: '1.2-2' }}</td> ₹{{ 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> </tr>
</tfoot> </tfoot>
</table> </table>
@@ -71,3 +71,68 @@
background: #eff6ff; 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;
}
@@ -10,6 +10,7 @@ import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { AuthenticationService } from '../../core/authentication/authentication.service'; import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ExportSheetSpec, exportWorkbook } from '../cashier-export';
interface SummaryRow { interface SummaryRow {
code: string; code: string;
@@ -70,6 +71,15 @@ export class CashClearingTransferSummaryComponent {
showDetails = false; showDetails = false;
/** GL/channel filter for the Transaction Details view — null means "All". */ /** GL/channel filter for the Transaction Details view — null means "All". */
glFilter: string | null = null; glFilter: string | null = null;
/** True while an Export to Excel workbook is being built — disables the button to prevent double-clicks. */
generating = false;
private readonly tabLabels: Record<TabKey, string> = {
cash: 'Cash',
transfer: 'Transfer',
clearing: 'Clearing',
digital: 'Digital'
};
/** True when the logged-in user belongs to the Head Office (sees both branches + consolidated view). */ /** True when the logged-in user belongs to the Head Office (sees both branches + consolidated view). */
isHeadOffice = false; isHeadOffice = false;
@@ -1481,6 +1491,52 @@ export class CashClearingTransferSummaryComponent {
return this.activeTab === 'digital' ? row.code : row.name; return this.activeTab === 'digital' ? row.code : row.name;
} }
/** GL name for the code tracked on this txn row, so the Transaction Details journal can show
* "which GL" alongside the account, not just a bare code. */
glName(code: string): string {
return this.rowsForActiveTab.find((r) => r.code === code)?.name || code;
}
/**
* Every transaction here is only ever recorded as ONE leg (whichever GL this Cash/Clearing/
* Transfer/Digital screen tracks) — the offsetting leg (the customer's own account, a
* settlement/clearing-house account, etc.) was never modelled as a separate GL row. Rather
* than leaving that side blank, this derives a sensible label for it so the Transaction
* Details view can show the FULL double entry (Dr leg + Cr leg, always equal amounts) —
* exactly what a real CBS "Day Book"/Journal Register shows, and what makes it possible to
* see at a glance that a transaction is complete and ready for reconciliation.
*
* For internal cash-movement postings (ATM cash-in-transit, currency chest, denomination
* shortages, CTS grid rounding) the account name already describes the SAME leg rather than a
* distinct counter-party, so those get a purpose-built contra label. Everything else already
* has a descriptive counter-party name (customer, vendor, bank branch, government department)
* that IS the natural contra account, so it's used as-is — WITHOUT repeating the account/
* reference number, since that number is already shown against the tracked GL leg on the
* other line; showing it a second time here would make both lines look like the same account,
* defeating the point of a "Balanced" two-sided entry.
*/
contraLabel(t: TxnRow): string {
const p = t.particulars.toLowerCase();
if (p.includes('atm') && (p.includes('vault') || p.includes('loading') || p.includes('surrendered'))) {
return 'ATM Cash-in-Transit Account';
}
if (p.includes('currency chest')) {
return 'Currency Chest Account (Sponsor Bank)';
}
if (p.includes('denomination') || p.includes('shortage adjustment')) {
return 'Cash Difference / Shortage Suspense Account';
}
if (p.includes('cts grid rounding')) {
return 'CTS Grid Rounding Suspense Account';
}
return t.accountName;
}
/** The single non-zero amount on this leg — always what the derived contra leg mirrors. */
legAmount(t: TxnRow): number {
return t.debit > 0 ? t.debit : t.credit;
}
/** Credit total for a GL row — always summed from its own txn lines, never a separate hand-entered number. */ /** Credit total for a GL row — always summed from its own txn lines, never a separate hand-entered number. */
rowCredit(row: SummaryRow, txns: TxnRow[]): number { rowCredit(row: SummaryRow, txns: TxnRow[]): number {
return txns.filter((t) => t.glCode === row.code).reduce((sum, t) => sum + t.credit, 0); return txns.filter((t) => t.glCode === row.code).reduce((sum, t) => sum + t.credit, 0);
@@ -1566,4 +1622,181 @@ export class CashClearingTransferSummaryComponent {
return { code: b.code, name: b.name, opening, credit, debit, closing: opening + credit - debit }; return { code: b.code, name: b.name, opening, credit, debit, closing: opening + credit - debit };
}); });
} }
/**
* Exports the full report for the currently viewed scope (own branch, a drilled-into branch,
* or "All Branches" consolidated) — every category (Cash/Transfer/Clearing/Digital), not just
* the tab on screen, so the workbook is a complete record. "Summary" mirrors the Opening/
* Credit/Debit/Closing view; "Transaction Details" mirrors the full double-entry journal view
* (same glName/contraLabel/legAmount derivations, so the export can never disagree with what's
* on screen); "Branch Breakup" is added only when Head Office is viewing the consolidated view.
*/
async exportExcel(): Promise<void> {
this.generating = true;
try {
const summaryRows: (string | number | null)[][] = [];
const journalRows: (string | number | null)[][] = [];
for (const tab of this.allTabs) {
const label = this.tabLabels[tab];
const rows = this.effectiveRows(tab);
const txns = this.effectiveTxns(tab);
rows.forEach((r) => {
summaryRows.push([
label,
r.code,
r.name,
r.opening,
this.rowCredit(r, txns),
this.rowDebit(r, txns),
this.closing(r, txns)
]);
});
summaryRows.push([
`${label} Total`,
'',
'',
this.total(rows, txns, 'opening'),
this.total(rows, txns, 'credit'),
this.total(rows, txns, 'debit'),
this.totalClosing(rows, txns)
]);
txns.forEach((t) => {
const glText = rows.find((r) => r.code === t.glCode)?.name || t.glCode;
journalRows.push([
label,
this.asOnDate,
this.display(t.voucherNo),
'Dr',
t.debit > 0 ? t.glCode : '',
t.debit > 0 ? `${glText} — ${this.display(t.accountNo)} (${t.accountName})` : this.contraLabel(t),
this.legAmount(t),
null,
'Balanced'
]);
journalRows.push([
label,
this.asOnDate,
this.display(t.voucherNo),
'Cr',
t.credit > 0 ? t.glCode : '',
t.credit > 0 ? `${glText} — ${this.display(t.accountNo)} (${t.accountName})` : this.contraLabel(t),
null,
this.legAmount(t),
'Balanced'
]);
});
}
summaryRows.push([
'Grand Total',
'',
'',
this.grandOpening,
this.grandCredit,
this.grandDebit,
this.grandClosing
]);
const sheets: ExportSheetSpec[] = [
{
sheetName: 'Summary',
title: 'Cash / Clearing / Transfer - Summary Report',
subtitle: `Scope: ${this.scopeName} · As On: ${this.asOnDate}`,
headers: [
'Category',
'GL Code',
'Particulars',
'Opening',
'Credit',
'Debit',
'Closing'
],
widths: [
12,
12,
34,
16,
16,
16,
16
],
rows: summaryRows
},
{
sheetName: 'Transaction Details',
title: 'Transaction Details — Full Double Entry',
subtitle: `Scope: ${this.scopeName} · As On: ${this.asOnDate}`,
headers: [
'Category',
'Date',
'Voucher / Ref No',
'Dr / Cr',
'GL Code',
'GL / Account',
'Debit',
'Credit',
'Status'
],
widths: [
12,
12,
20,
8,
10,
46,
16,
16,
12
],
rows: journalRows
}
];
if (this.isHeadOffice && this.selectedBranchCode === 'ALL') {
sheets.push({
sheetName: 'Branch Breakup',
title: 'Branch-wise Break-up',
subtitle: `As On: ${this.asOnDate}`,
headers: [
'Branch',
'Opening',
'Credit',
'Debit',
'Closing'
],
widths: [
22,
16,
16,
16,
16
],
rows: [
...this.branchBreakup.map((b) => [
b.name,
b.opening,
b.credit,
b.debit,
b.closing
]),
[
'All Branches',
this.grandOpening,
this.grandCredit,
this.grandDebit,
this.grandClosing
]
]
});
}
await exportWorkbook(sheets, `Cash-Clearing-Transfer-Summary-${this.scopeName}-${this.asOnDate}`);
} catch (err) {
console.error('Cash/Clearing/Transfer export failed', err);
} finally {
this.generating = false;
}
}
} }
+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);
}
+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;
}
}
+139 -31
View File
@@ -350,6 +350,10 @@
<fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA <fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA
<fa-icon icon="caret-down" class="ni-caret"></fa-icon> <fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a> </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'"> <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="hand-holding-usd" class="ni-ico"></fa-icon>Advances
<fa-icon icon="caret-down" class="ni-caret"></fa-icon> <fa-icon icon="caret-down" class="ni-caret"></fa-icon>
@@ -442,6 +446,10 @@
<fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA <fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA
<fa-icon icon="caret-down" class="ni-caret"></fa-icon> <fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a> </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 <a
class="ni" class="ni"
tabindex="0" tabindex="0"
@@ -579,6 +587,13 @@
<button mat-menu-item [routerLink]="['/reports', 'Accounting']" tabindex="0"> <button mat-menu-item [routerLink]="['/reports', 'Accounting']" tabindex="0">
<i class="fa fa-calculator menu-item-icon"></i> Accounting <i class="fa fa-calculator menu-item-icon"></i> Accounting
</button> </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> </mat-menu>
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup, <!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
@@ -857,20 +872,34 @@
</div> </div>
<div class="gl-group"> <div class="gl-group">
<div class="gl-group-title">GL Reports</div> <div class="gl-group-title">GL Reports</div>
<a class="gl-link" [class.hidden]="!matchesGlSearch('GL Average Balance')" (click)="glMenuOpen = false" <a
><fa-icon icon="calculator" class="gl-icon"></fa-icon> GL Average Balance</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" <a
><fa-icon icon="project-diagram" class="gl-icon"></fa-icon> GL Mapping for Report</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 <a
class="gl-link" class="gl-link"
[class.hidden]="!matchesGlSearch('Account wise Balance List')" [class.hidden]="!matchesGlSearch('Account wise Balance List')"
routerLink="/gl/reports/account-wise-balance"
(click)="glMenuOpen = false" (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" <a
><fa-icon icon="chart-pie" class="gl-icon"></fa-icon> Distribution of Balance</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 <a
class="gl-link" class="gl-link"
@@ -886,8 +915,9 @@
<a <a
class="gl-link" class="gl-link"
[class.hidden]="!matchesGlSearch('Client wise Balance Report')" [class.hidden]="!matchesGlSearch('Client wise Balance Report')"
routerLink="/gl/reports/client-wise-balance"
(click)="glMenuOpen = false" (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 <a
class="gl-link" class="gl-link"
@@ -897,17 +927,29 @@
><fa-icon icon="exclamation-circle" class="gl-icon" style="color: #b45309"></fa-icon> Contingent ><fa-icon icon="exclamation-circle" class="gl-icon" style="color: #b45309"></fa-icon> Contingent
Liabilities</a Liabilities</a
> >
<a class="gl-link" [class.hidden]="!matchesGlSearch('Banker Head Report')" (click)="glMenuOpen = false" <a
><fa-icon icon="university" class="gl-icon"></fa-icon> Banker Head Report</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>
<div class="gl-group"> <div class="gl-group">
<div class="gl-group-title">Registers</div> <div class="gl-group-title">Registers</div>
<a class="gl-link" [class.hidden]="!matchesGlSearch('Sundry Register')" (click)="glMenuOpen = false" <a
><fa-icon icon="clipboard-list" class="gl-icon"></fa-icon> Sundry Register</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" <a
><fa-icon icon="receipt" class="gl-icon"></fa-icon> Voucher Register</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 <a
class="gl-link" class="gl-link"
@@ -1240,7 +1282,7 @@
<div class="deposit-menu-group"> <div class="deposit-menu-group">
<div class="deposit-group-title">NPA Reports</div> <div class="deposit-group-title">NPA Reports</div>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/interest"> <button mat-menu-item tabindex="0" routerLink="/npa/reports/interest">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> NPA Interest <fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> NPA Report
</button> </button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/status"> <button mat-menu-item tabindex="0" routerLink="/npa/reports/status">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> NPA Status Report <fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> NPA Status Report
@@ -1264,6 +1306,9 @@
<button mat-menu-item tabindex="0" routerLink="/npa/reports/provision"> <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 <fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> NPA with Provision and Required Provision
</button> </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> </div>
<!-- NPA Classification --> <!-- NPA Classification -->
<div class="deposit-menu-group"> <div class="deposit-menu-group">
@@ -1275,6 +1320,58 @@
<fa-icon icon="flag" class="menu-item-icon"></fa-icon> SMA Report <fa-icon icon="flag" class="menu-item-icon"></fa-icon> SMA Report
</button> </button>
</div> </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> </div>
</mat-menu> </mat-menu>
@@ -1306,27 +1403,27 @@
<!-- Core / Books --> <!-- Core / Books -->
<div class="deposit-menu-group"> <div class="deposit-menu-group">
<div class="deposit-group-title">Core &amp; Books</div> <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> <fa-icon icon="receipt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Voucher Reconciliation Voucher Reconciliation
</button> </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> <fa-icon icon="random" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Sundry Dr / Cr Reconciliation Sundry Dr / Cr Reconciliation
</button> </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> <fa-icon icon="chart-line" class="menu-item-icon" style="color: #0d9488"></fa-icon>
P&amp;L Impact Reconciliation P&amp;L Impact Reconciliation
</button> </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> <fa-icon icon="book-open" class="menu-item-icon" style="color: #0d9488"></fa-icon>
GL Reconciliation GL Reconciliation
</button> </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> <fa-icon icon="cube" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Fixed Assets Reconciliation Fixed Assets Reconciliation
</button> </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> <fa-icon icon="sitemap" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Inter-Company Reconciliation Inter-Company Reconciliation
</button> </button>
@@ -1335,19 +1432,19 @@
<!-- Bank & Clearing --> <!-- Bank & Clearing -->
<div class="deposit-menu-group"> <div class="deposit-menu-group">
<div class="deposit-group-title">Bank &amp; Clearing</div> <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> <fa-icon icon="university" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Bank Reconciliation Bank Reconciliation
</button> </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> <fa-icon icon="code-branch" class="menu-item-icon" style="color: #0d9488"></fa-icon>
API Based Bank Reconciliation API Based Bank Reconciliation
</button> </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> <fa-icon icon="exchange" class="menu-item-icon" style="color: #0d9488"></fa-icon>
NEFT / RTGS Reconciliation NEFT / RTGS Reconciliation
</button> </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> <fa-icon icon="hand-holding-usd" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Clearing Reconciliation Clearing Reconciliation
</button> </button>
@@ -1356,15 +1453,15 @@
<!-- Digital / Payments --> <!-- Digital / Payments -->
<div class="deposit-menu-group"> <div class="deposit-menu-group">
<div class="deposit-group-title">Digital &amp; Payments</div> <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> <fa-icon icon="mobile-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
UDIR (IMPS / UPI / ATM) UDIR (IMPS / UPI / ATM)
</button> </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> <fa-icon icon="project-diagram" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Three-way Digital Reconciliation Three-way Digital Reconciliation
</button> </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> <fa-icon icon="sync-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
NACH Reconciliation NACH Reconciliation
</button> </button>
@@ -1377,7 +1474,7 @@
<fa-icon icon="money-check-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="money-check-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
DD / Pay Order Reconciliation DD / Pay Order Reconciliation
</button> </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> <fa-icon icon="coins" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Share Member Reconciliation Share Member Reconciliation
</button> </button>
@@ -1386,11 +1483,11 @@
<!-- Compliance & Financials --> <!-- Compliance & Financials -->
<div class="deposit-menu-group"> <div class="deposit-menu-group">
<div class="deposit-group-title">Compliance &amp; Financials</div> <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> <fa-icon icon="file-invoice" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Invoice Payment Invoice Payment
</button> </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> <fa-icon icon="percentage" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Tax Reconciliation Tax Reconciliation
</button> </button>
@@ -1502,6 +1599,9 @@
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 98 <fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 98
</button> </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"> <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 <fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 121
</button> </button>
@@ -1706,6 +1806,14 @@
> >
<fa-icon icon="shield-alt" class="gl-icon"></fa-icon> AML <fa-icon icon="shield-alt" class="gl-icon"></fa-icon> AML
</a> </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> </div>
<!-- OSS Reports --> <!-- OSS Reports -->
<div class="gl-group"> <div class="gl-group">
+27
View File
@@ -677,6 +677,26 @@ $gray-700: #334155;
opacity: 0.7; 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 ===== // ===== Nav Dropdown Menus =====
::ng-deep .nav-dropdown { ::ng-deep .nav-dropdown {
border-radius: 8px !important; 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 Mega Panel — 5 columns, teal accent to match the icon palette */
.reconciliation-grid { .reconciliation-grid {
grid-template-columns: repeat(5, minmax(190px, 1fr)); grid-template-columns: repeat(5, minmax(190px, 1fr));
+357 -2
View File
@@ -6,6 +6,10 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import { BRANCHES, BranchMaster } from '../npa/npa-data';
export { BRANCHES, BranchMaster };
/** /**
* Sample FIU-IND data — schema mirrors the FINnet 2.0 CSV submission templates * Sample FIU-IND data — schema mirrors the FINnet 2.0 CSV submission templates
* (TC1, TC2, KC1, KC2, Account_Detail, Account_Person_Relation). * (TC1, TC2, KC1, KC2, Account_Detail, Account_Person_Relation).
@@ -20,7 +24,15 @@
*/ */
export type ReportType = 'CTR' | 'STR' | 'CCR' | 'NTR' | 'CBWTR'; export type ReportType = 'CTR' | 'STR' | 'CCR' | 'NTR' | 'CBWTR';
export type StrStatus = 'draft' | 'po-review' | 'filed' | 'rejected'; /**
* Lifecycle of an STR case.
* draft — raised, not yet with the Principal Officer
* po-review — under Principal Officer investigation
* awaiting-filing — concluded suspicious; STR XML must still be submitted to FIU-IND
* filed — submitted and FIU-IND acknowledgement recorded
* rejected — concluded clean; nothing filed
*/
export type StrStatus = 'draft' | 'po-review' | 'awaiting-filing' | 'filed' | 'rejected';
/** TC2 — Branch / cash transaction record */ /** TC2 — Branch / cash transaction record */
export interface CtrTransaction { export interface CtrTransaction {
@@ -42,7 +54,17 @@ export interface CtrTransaction {
declaration: string; declaration: string;
/** Customer name resolved from KC1 (UI convenience, not in actual TC2 schema) */ /** Customer name resolved from KC1 (UI convenience, not in actual TC2 schema) */
customerName?: string; customerName?: string;
complianceStatus?: 'PENDING' | 'WHITELISTED' | 'SUSPICIOUS'; /**
* CTR does not judge suspicion. It records only whether the source of funds was
* evidenced at the counter (CTR filed, nothing further) or could not be, in which
* case the transaction is referred to STR. The suspicious/clean verdict itself
* belongs to the Principal Officer on the STR side.
*/
complianceStatus?: 'PENDING' | 'EVIDENCED' | 'REFERRED';
/** Document that evidenced the source of funds, when verified at the counter. */
evidenceDocType?: string;
evidenceReference?: string;
evidencedOn?: string;
} }
/** Outcome of the Principal Officer's investigation into an STR case */ /** Outcome of the Principal Officer's investigation into an STR case */
@@ -64,6 +86,10 @@ export interface StrTransaction extends CtrTransaction {
poRemarks?: string; poRemarks?: string;
/** Set once the Principal Officer concludes the investigation */ /** Set once the Principal Officer concludes the investigation */
poFinding?: PoFinding; poFinding?: PoFinding;
/** FIU-IND acknowledgement number, recorded only after the STR is actually submitted */
fiuReferenceNo?: string;
/** Date the STR was submitted on the FINnet portal */
filedOn?: string;
} }
/** KC1 — Individual customer card (subset of the 70+ fields, only what UI shows) */ /** KC1 — Individual customer card (subset of the 70+ fields, only what UI shows) */
@@ -133,6 +159,25 @@ export const FIU_BANK = {
cbwtrThreshold: 500000 // CBWTR threshold ₹5 L cbwtrThreshold: 500000 // CBWTR threshold ₹5 L
}; };
/**
* CTR/STR records below use the bank's own internal numeric branch codes (as FINnet's TC2
* schema expects), not the app-wide BranchMaster codes (BR01, BR02, ...) every other module
* scopes Head-Office-vs-branch access by. This maps one to the other so a branch-level login's
* officeName can be matched consistently, the same way NPA/Reconciliation/Loan Origination do.
*/
export const FIU_BRANCH_CODE_TO_CANONICAL: Record<string, string> = {
'1': 'BR01',
'2': 'BR02',
'3': 'BR03',
'4': 'BR04',
'5': 'BR05',
'6': 'BR06',
'8': 'BR07',
'9': 'BR08',
'10': 'BR02',
'12': 'BR04'
};
/** /**
* ── CTR transactions (FINnet TC2-style) ── * ── CTR transactions (FINnet TC2-style) ──
* Only CASH transactions that breach the RBI/PMLA reporting limit of ₹10,00,000 * Only CASH transactions that breach the RBI/PMLA reporting limit of ₹10,00,000
@@ -275,6 +320,316 @@ export const CTR_TXNS: CtrTransaction[] = [
} }
]; ];
/**
* ── TC1: ATM / card transactions ──
*
* TC2 carries what happened at the counter; TC1 carries what happened at the
* card rails. Both feed the same CTR, because an ATM cash withdrawal is still
* a cash transaction and counts towards the customer's monthly aggregate.
*
* Every row here belongs to an account that is already in CTR_TXNS and carries
* the same reportRefNo, so the six FINnet files reconcile against each other.
*/
export interface CardTransaction {
reportType: ReportType;
reportRefNo: number;
branchCode: string;
accountNumber: string;
/** FINnet account-type code, same codes used in Account_Detail. */
accountType: string;
relationshipFlag: 'Customer' | 'Non-Customer';
/** Settled at the switch, or declined / reversed. */
txnStatus: 'Success' | 'Failed' | 'Reversed';
txnId: number;
txnDate: string;
txnTime: string;
depositOrWithdrawal: 'Deposit' | 'Withdrawal';
txnAmount: number;
cardType: 'RuPay Debit' | 'VISA Debit' | 'Mastercard Debit';
/** Masked to the last four digits — the PAN itself is never put in the file. */
cardNumber: string;
/** FINnet country code; 95 is India, as used across the KC files. */
cardIssuingCountry: string;
cardIssuingBank: string;
/** NFS / NPCI switch reference for the leg. */
switchTxnId: string;
atmId: string;
/** UI convenience, not part of the TC1 schema. */
customerName?: string;
}
export const TC1_TXNS: CardTransaction[] = [
// Ref 1 — ANAND KRISHNAN: routine ATM usage alongside a large counter deposit.
{
reportType: 'CTR',
reportRefNo: 1,
branchCode: '1',
accountNumber: '110001101000838',
accountType: '211',
relationshipFlag: 'Customer',
txnStatus: 'Success',
txnId: 9001,
txnDate: '06-01-2026',
txnTime: '19:42:11',
depositOrWithdrawal: 'Withdrawal',
txnAmount: 20000,
cardType: 'RuPay Debit',
cardNumber: 'XXXXXXXXXXXX4417',
cardIssuingCountry: '95',
cardIssuingBank: 'CBSNexora Bank Ltd',
switchTxnId: 'NFS26010600418',
atmId: 'NEXA0001',
customerName: 'ANAND KRISHNAN'
},
{
reportType: 'CTR',
reportRefNo: 1,
branchCode: '1',
accountNumber: '110001101000838',
accountType: '211',
relationshipFlag: 'Customer',
txnStatus: 'Success',
txnId: 9002,
txnDate: '18-01-2026',
txnTime: '11:07:53',
depositOrWithdrawal: 'Withdrawal',
txnAmount: 25000,
cardType: 'RuPay Debit',
cardNumber: 'XXXXXXXXXXXX4417',
cardIssuingCountry: '95',
cardIssuingBank: 'CBSNexora Bank Ltd',
switchTxnId: 'NFS26011800911',
atmId: 'NEXA0003',
customerName: 'ANAND KRISHNAN'
},
// Ref 2 — MEERA SUNDARAM: card cash-out at another bank's ATM.
{
reportType: 'CTR',
reportRefNo: 2,
branchCode: '3',
accountNumber: '110001204000712',
accountType: '215',
relationshipFlag: 'Customer',
txnStatus: 'Success',
txnId: 9003,
txnDate: '09-01-2026',
txnTime: '20:15:36',
depositOrWithdrawal: 'Withdrawal',
txnAmount: 10000,
cardType: 'VISA Debit',
cardNumber: 'XXXXXXXXXXXX8820',
cardIssuingCountry: '95',
cardIssuingBank: 'CBSNexora Bank Ltd',
switchTxnId: 'NFS26010901277',
atmId: 'SBIN004512',
customerName: 'MEERA SUNDARAM'
},
{
reportType: 'CTR',
reportRefNo: 2,
branchCode: '3',
accountNumber: '110001204000712',
accountType: '215',
relationshipFlag: 'Customer',
// Declined for insufficient balance. Failed legs are still reported: the
// attempt itself is part of the customer's pattern.
txnStatus: 'Failed',
txnId: 9004,
txnDate: '09-01-2026',
txnTime: '20:17:02',
depositOrWithdrawal: 'Withdrawal',
txnAmount: 20000,
cardType: 'VISA Debit',
cardNumber: 'XXXXXXXXXXXX8820',
cardIssuingCountry: '95',
cardIssuingBank: 'CBSNexora Bank Ltd',
switchTxnId: 'NFS26010901284',
atmId: 'SBIN004512',
customerName: 'MEERA SUNDARAM'
},
// Ref 3 — HARPREET SINGH BEDI: cash recycler deposit through the card.
{
reportType: 'CTR',
reportRefNo: 3,
branchCode: '6',
accountNumber: '110001307000455',
accountType: '215',
relationshipFlag: 'Customer',
txnStatus: 'Success',
txnId: 9005,
txnDate: '12-01-2026',
txnTime: '13:28:44',
depositOrWithdrawal: 'Deposit',
txnAmount: 49500,
cardType: 'RuPay Debit',
cardNumber: 'XXXXXXXXXXXX6193',
cardIssuingCountry: '95',
cardIssuingBank: 'CBSNexora Bank Ltd',
switchTxnId: 'NFS26011200630',
atmId: 'NEXA0002-CRM',
customerName: 'HARPREET SINGH BEDI'
},
// Ref 4 — FAROOQ ABDULLAH KHAN: repeated near-limit withdrawals in one day.
{
reportType: 'CTR',
reportRefNo: 4,
branchCode: '2',
accountNumber: '110001408000933',
accountType: '211',
relationshipFlag: 'Customer',
txnStatus: 'Success',
txnId: 9006,
txnDate: '16-01-2026',
txnTime: '09:03:19',
depositOrWithdrawal: 'Withdrawal',
txnAmount: 50000,
cardType: 'Mastercard Debit',
cardNumber: 'XXXXXXXXXXXX2075',
cardIssuingCountry: '95',
cardIssuingBank: 'CBSNexora Bank Ltd',
switchTxnId: 'NFS26011600142',
atmId: 'NEXA0004',
customerName: 'FAROOQ ABDULLAH KHAN'
},
{
reportType: 'CTR',
reportRefNo: 4,
branchCode: '2',
accountNumber: '110001408000933',
accountType: '211',
relationshipFlag: 'Customer',
txnStatus: 'Success',
txnId: 9007,
txnDate: '16-01-2026',
txnTime: '09:06:41',
depositOrWithdrawal: 'Withdrawal',
txnAmount: 50000,
cardType: 'Mastercard Debit',
cardNumber: 'XXXXXXXXXXXX2075',
cardIssuingCountry: '95',
cardIssuingBank: 'CBSNexora Bank Ltd',
switchTxnId: 'NFS26011600149',
atmId: 'NEXA0004',
customerName: 'FAROOQ ABDULLAH KHAN'
},
{
reportType: 'CTR',
reportRefNo: 4,
branchCode: '2',
accountNumber: '110001408000933',
accountType: '211',
relationshipFlag: 'Customer',
// Dispensed short and auto-reversed by the switch the same evening.
txnStatus: 'Reversed',
txnId: 9008,
txnDate: '16-01-2026',
txnTime: '09:09:05',
depositOrWithdrawal: 'Withdrawal',
txnAmount: 50000,
cardType: 'Mastercard Debit',
cardNumber: 'XXXXXXXXXXXX2075',
cardIssuingCountry: '95',
cardIssuingBank: 'CBSNexora Bank Ltd',
switchTxnId: 'NFS26011600155',
atmId: 'NEXA0004',
customerName: 'FAROOQ ABDULLAH KHAN'
},
// Ref 5 — DEEPIKA RANGANATHAN
{
reportType: 'CTR',
reportRefNo: 5,
branchCode: '9',
accountNumber: '110001509000268',
accountType: '215',
relationshipFlag: 'Customer',
txnStatus: 'Success',
txnId: 9009,
txnDate: '21-01-2026',
txnTime: '17:51:28',
depositOrWithdrawal: 'Withdrawal',
txnAmount: 15000,
cardType: 'RuPay Debit',
cardNumber: 'XXXXXXXXXXXX9354',
cardIssuingCountry: '95',
cardIssuingBank: 'CBSNexora Bank Ltd',
switchTxnId: 'NFS26012101508',
atmId: 'HDFC009871',
customerName: 'DEEPIKA RANGANATHAN'
},
// Ref 6 — RAJENDRA PRASAD YADAV
{
reportType: 'CTR',
reportRefNo: 6,
branchCode: '5',
accountNumber: '110001610000841',
accountType: '215',
relationshipFlag: 'Customer',
txnStatus: 'Success',
txnId: 9010,
txnDate: '23-01-2026',
txnTime: '10:34:12',
depositOrWithdrawal: 'Withdrawal',
txnAmount: 40000,
cardType: 'RuPay Debit',
cardNumber: 'XXXXXXXXXXXX1268',
cardIssuingCountry: '95',
cardIssuingBank: 'CBSNexora Bank Ltd',
switchTxnId: 'NFS26012300287',
atmId: 'NEXA0005',
customerName: 'RAJENDRA PRASAD YADAV'
},
{
reportType: 'CTR',
reportRefNo: 6,
branchCode: '5',
accountNumber: '110001610000841',
accountType: '215',
relationshipFlag: 'Customer',
txnStatus: 'Success',
txnId: 9011,
txnDate: '28-01-2026',
txnTime: '18:22:47',
depositOrWithdrawal: 'Withdrawal',
txnAmount: 35000,
cardType: 'RuPay Debit',
cardNumber: 'XXXXXXXXXXXX1268',
cardIssuingCountry: '95',
cardIssuingBank: 'CBSNexora Bank Ltd',
switchTxnId: 'NFS26012801733',
atmId: 'NEXA0005',
customerName: 'RAJENDRA PRASAD YADAV'
},
// Ref 7 — SHABANA TARIQ MIRZA: the account's card is used by a third party,
// which is why the relationship flag differs from the rest.
{
reportType: 'CTR',
reportRefNo: 7,
branchCode: '12',
accountNumber: '110001711000527',
accountType: '215',
relationshipFlag: 'Non-Customer',
txnStatus: 'Success',
txnId: 9012,
txnDate: '27-01-2026',
txnTime: '21:08:33',
depositOrWithdrawal: 'Withdrawal',
txnAmount: 30000,
cardType: 'VISA Debit',
cardNumber: 'XXXXXXXXXXXX7741',
cardIssuingCountry: '95',
cardIssuingBank: 'CBSNexora Bank Ltd',
switchTxnId: 'NFS26012702094',
atmId: 'ICIC007233',
customerName: 'SHABANA TARIQ MIRZA'
}
];
/** /**
* ── STR cases ── * ── STR cases ──
* Raised either by the AML rules engine or by an officer flagging a CTR row as * Raised either by the AML rules engine or by an officer flagging a CTR row as
+357 -263
View File
@@ -14,12 +14,25 @@
<div> <div>
<h2 class="fiu-head-t">Financial Intelligence Unit – India (FIU-IND)</h2> <h2 class="fiu-head-t">Financial Intelligence Unit – India (FIU-IND)</h2>
<div class="fiu-head-s"> <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 &middot; PMLA 2002 / FINnet 2.0
</div> </div>
</div> </div>
</div> </div>
<div class="fiu-head-meta"> <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> <div>
<span class="fiu-meta-l">Principal Officer</span><span class="fiu-meta-v">{{ bank.principalOfficer }}</span> <span class="fiu-meta-l">Principal Officer</span><span class="fiu-meta-v">{{ bank.principalOfficer }}</span>
</div> </div>
@@ -96,6 +109,52 @@
</div> </div>
</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 --> <!-- Compliance cards -->
<div class="fiu-compliance"> <div class="fiu-compliance">
<div class="dc-card"> <div class="dc-card">
@@ -136,257 +195,106 @@
<!-- CTR --> <!-- CTR -->
@if (activeTab === 'ctr') { @if (activeTab === 'ctr') {
<div class="fiu-section"> <div class="fiu-section">
<!-- LIVE: cash-related CTR threshold alerts --> <!-- FINNET FILE SEQUENCE — the CTR submission, in prescribed order -->
<div class="fiu-form-meta"> <div class="seq-block">
<div class="fiu-form-title"> <div class="fiu-form-meta">
<h3>Live CTR Threshold Alerts</h3> <div class="fiu-form-title">
<p> <h3>CTR Submission Files — FINnet 2.0</h3>
Mechanical cash transaction threshold breaches — review and whitelist or escalate to Principal Officer <p>
</p> The CTR is built in this order: the account is described first, then who is behind it, then the two
</div> transaction files. Every grid below is the exact content of its CSV. The customer cards (KC1 / KC2) travel
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter" (ngModelChange)="loadAmlAlerts()"> with the same bundle and are worked on the Customer Cards tab.
<option value="">All statuses</option> </p>
<option value="RAISED">Raised</option> </div>
<option value="UNDER_REVIEW">Under Review</option> <button class="fiu-btn fiu-btn-xls" (click)="exportAll()">
<option value="WHITELISTED">Whitelisted</option> <fa-icon icon="file-csv"></fa-icon> Download full bundle
<option value="ESCALATED">Escalated</option>
</select>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>ID</th>
<th>Client</th>
<th>Type</th>
<th>Rule</th>
<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 ctrAlerts; track alert.id) {
<tr>
<td>{{ alert.id }}</td>
<td>{{ alert.clientDisplayName }}</td>
<td>{{ alert.transactionType }}</td>
<td>{{ alert.ruleCode }} &mdash; {{ alert.ruleDescription }}</td>
<td class="ta-r mono">&#8377;{{ alert.amount | number: '1.0-0' }}</td>
<td>{{ alert.raisedOn | date: 'medium' }}</td>
<td>
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
</td>
<td class="ta-c">
<span [class]="alertRouteClass(alert.status)">{{ alertRouteLabel(alert.status) }}</span>
</td>
<td>
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
<input
type="text"
class="fiu-inp"
placeholder="Review notes"
[(ngModel)]="amlReviewNotes[alert.id]"
/>
} @else {
<span class="muted">{{ alert.reviewNotes }}</span>
}
</td>
<td>
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
<div class="fiu-action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'">
<button class="fiu-btn fiu-btn-g" (click)="whitelistAlert(alert)">Whitelist</button>
<button class="fiu-btn fiu-btn-xml" (click)="escalateAlert(alert)">Escalate</button>
</div>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="10" class="fiu-empty">No live 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-ctr">CTR only</span> whitelisted — cleared, no STR needed &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> </button>
</div> </div>
</div>
<div class="fiu-tbl-wrap"> <!-- Scope: these controls decide which transactions the six files carry. -->
<table class="fiu-tbl"> <div class="fiu-filters">
<thead> <div class="fiu-filter">
<tr> <label>Type</label>
<th>Month</th> <select class="fiu-inp" [(ngModel)]="ctrFilterMode">
<th>Generated On</th> <option value="">All</option>
<th class="ta-c">Records</th> <option value="Deposit">Deposit</option>
<th>Status</th> <option value="Withdrawal">Withdrawal</option>
<th>Submitted On</th> </select>
</tr> </div>
</thead> <div class="fiu-filter">
<tbody> <label>Account contains</label>
@for (r of amlCtrReports; track r.id) { <input class="fiu-inp" type="text" [(ngModel)]="ctrFilterAccount" placeholder="A/c partial" />
<tr> </div>
<td>{{ r.reportMonth }}</td> <div class="fiu-filter">
<td>{{ r.generatedOn | date: 'medium' }}</td> <label>Min Amount (&#8377;)</label>
<td class="ta-c">{{ r.recordCount }}</td> <input class="fiu-inp" type="number" [(ngModel)]="ctrFilterMinAmount" placeholder="0" />
<td> </div>
<span [class]="amlStatusClass(r.status)">{{ r.status }}</span> <button class="fiu-btn fiu-btn-g" (click)="resetCtrFilters()">Reset</button>
</td> </div>
<td>{{ r.submittedOn ? (r.submittedOn | date: 'medium') : '—' }}</td>
</tr> <!-- Step rail -->
} @empty { <div class="seq-rail">
<tr> @for (f of finnetSequence; track f.key) {
<td colspan="5" class="fiu-empty">No CTR reports generated yet.</td> <button class="seq-step" [class.active]="activeFinnetFile === f.key" (click)="showFinnetFile(f.key)">
</tr> <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>
} }
</tbody> }
</table> </div>
</div>
<!-- FINnet export template (separate concern — TC1/TC2 file-format generation) --> <!-- Open file -->
<div class="fiu-form-meta"> <div class="seq-panel">
<div class="fiu-form-title"> <div class="seq-panel-h">
<h3>FINnet TC2 Export Template</h3> <div>
<p> <h4>Step {{ openFinnetFile.step }} — {{ openFinnetFile.title }}</h4>
All cash transactions ≥ &#8377;{{ bank.cashThreshold | number }} (single or aggregate per month per account) <p>{{ openFinnetFile.purpose }}</p>
</p> </div>
</div> <button class="fiu-btn fiu-btn-csv" (click)="downloadOpenFinnetFile()">
<button class="fiu-btn fiu-btn-csv" (click)="exportTc2()"> <fa-icon icon="file-csv"></fa-icon> {{ openFinnetFile.name }}.csv
<fa-icon icon="file-csv"></fa-icon> Download TC2.csv </button>
</button> </div>
</div>
<div class="fiu-filters"> <div class="seq-tbl-wrap">
<div class="fiu-filter"> <table class="seq-tbl">
<label>Type</label> <thead>
<select class="fiu-inp" [(ngModel)]="ctrFilterMode"> <tr>
<option value="">All</option> <th class="seq-rownum">#</th>
<option value="Deposit">Deposit</option> @for (h of openFinnetFile.table.headers; track $index) {
<option value="Withdrawal">Withdrawal</option> <th>{{ h }}</th>
</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>
<th class="ta-c">Filed Under</th>
<th>Compliance Action</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>
<td class="ta-c">
<span class="report-pill report-ctr" title="Always included in the monthly CTR / TC2 file">CTR</span>
@if (t.complianceStatus === 'SUSPICIOUS') {
<span class="report-pill report-str" title="Also escalated to an STR case for Principal Officer review">+ STR</span>
} }
</td> </tr>
<td> </thead>
@if (!t.complianceStatus || t.complianceStatus === 'PENDING') { <tbody>
<div class="fiu-action-group" style="gap: 4px;"> @for (r of openFinnetFile.table.rows; track $index) {
<button class="fiu-btn" style="padding: 4px 8px; font-size: 11px; color: var(--f-green); border-color: var(--f-green); background: var(--f-green-l);" (click)="whitelistCtrTxn(t)"> <tr>
Whitelist <td class="seq-rownum">{{ $index + 1 }}</td>
</button> @for (c of r; track $index) {
<button class="fiu-btn" style="padding: 4px 8px; font-size: 11px; color: var(--f-red); border-color: var(--f-red); background: var(--f-red-l);" (click)="flagCtrTxnSuspicious(t)"> <td>{{ c }}</td>
Flag STR
</button>
</div>
} @else {
<span [class]="t.complianceStatus === 'WHITELISTED' ? 'status-pill status-whitelisted' : 'status-pill status-escalated'">
{{ t.complianceStatus }}
</span>
@if (t.complianceStatus === 'WHITELISTED') {
<div class="muted" style="margin-top: 3px;">Stays in CTR only</div>
} @else {
<button class="fiu-link-btn" (click)="openStrCase(t)">View in STR tab &rarr;</button>
} }
} </tr>
</td> } @empty {
</tr> <tr>
} @empty { <td [attr.colspan]="openFinnetFile.table.headers.length + 1" class="seq-empty">
<tr> @if (openFinnetFile.key === 'tc1') {
<td colspan="11" class="fiu-empty">No transactions match the filters.</td> No ATM or card transactions match the current scope. When the bank books none at all, TC1 is
</tr> still submitted as a header-only file — that is what FIU-IND expects.
} } @else {
</tbody> No rows for the current filters.
</table> }
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div> </div>
<p class="fiu-tbl-legend">
<span class="report-pill report-ctr">CTR</span> every cash txn ≥ threshold stays in the monthly CTR / TC2
file &middot;
<span class="report-pill report-str">+ STR</span> also flagged as suspicious and escalated to a separate STR
case (see the STR tab)
</p>
</div> </div>
} }
@@ -397,9 +305,7 @@
<div class="fiu-form-meta"> <div class="fiu-form-meta">
<div class="fiu-form-title"> <div class="fiu-form-title">
<h3>Live AML Alerts (Awaiting Triage)</h3> <h3>Live AML Alerts (Awaiting Triage)</h3>
<p> <p>Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate</p>
Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate
</p>
</div> </div>
</div> </div>
<div class="fiu-tbl-wrap"> <div class="fiu-tbl-wrap">
@@ -468,7 +374,7 @@
<span class="report-pill report-str">→ STR</span> escalated to Principal Officer as an STR case <span class="report-pill report-str">→ STR</span> escalated to Principal Officer as an STR case
</p> </p>
<hr style="border: 0; border-top: 1px solid var(--f-g200); margin: 8px 0;" /> <hr style="border: 0; border-top: 1px solid var(--f-g200); margin: 8px 0" />
<!-- LIVE: STR cases with full Principal Officer actions --> <!-- LIVE: STR cases with full Principal Officer actions -->
<div class="fiu-form-meta"> <div class="fiu-form-meta">
@@ -627,23 +533,26 @@
} @else if (derivedConclusion === 'NOT_SUSPICIOUS') { } @else if (derivedConclusion === 'NOT_SUSPICIOUS') {
<fa-icon icon="check-circle"></fa-icon> <fa-icon icon="check-circle"></fa-icon>
<span> <span>
<strong>Not suspicious.</strong> All {{ strChecklist.length }} points found genuine — the case will <strong>Not suspicious.</strong> All {{ strChecklist.length }} points found genuine — the case will be
be closed and nothing filed with FIU-IND. closed and nothing filed with FIU-IND.
</span> </span>
} @else { } @else {
<fa-icon icon="exclamation-triangle"></fa-icon> <fa-icon icon="exclamation-triangle"></fa-icon>
<span> <span>
<strong>Suspicious — STR must be filed.</strong> <strong>Suspicious — STR must be filed.</strong>
{{ failedFindings.length }} of {{ strChecklist.length }} points not satisfied: {{ failedFindings.length }} of {{ strChecklist.length }} points not satisfied:
{{ failedFindings[0].label }}@if (failedFindings.length > 1) { {{ failedFindings[0].label }}
@if (failedFindings.length > 1) {
<span> and {{ failedFindings.length - 1 }} more</span> <span> and {{ failedFindings.length - 1 }} more</span>
}. }
.
</span> </span>
} }
</div> </div>
<button class="fiu-btn fiu-btn-xml" [disabled]="!allAnswered" (click)="concludeStrCase()"> <button class="fiu-btn fiu-btn-xml" [disabled]="!allAnswered" (click)="concludeStrCase()">
Conclude as {{ allAnswered ? (derivedConclusion === 'SUSPICIOUS' ? 'Suspicious' : 'Not Suspicious') : '…' }} Conclude as
{{ allAnswered ? (derivedConclusion === 'SUSPICIOUS' ? 'Suspicious' : 'Not Suspicious') : '…' }}
</button> </button>
@if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') { @if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') {
@@ -675,8 +584,9 @@
<option value="">All</option> <option value="">All</option>
<option value="draft">Draft</option> <option value="draft">Draft</option>
<option value="po-review">PO Review</option> <option value="po-review">PO Review</option>
<option value="awaiting-filing">Awaiting Filing</option>
<option value="filed">Filed</option> <option value="filed">Filed</option>
<option value="rejected">Rejected</option> <option value="rejected">Closed — Clean</option>
</select> </select>
</div> </div>
<div class="fiu-filter"> <div class="fiu-filter">
@@ -733,6 +643,9 @@
<div> <div>
<label>Instrument</label><span>{{ s.instrumentType }} {{ s.instrumentId }}</span> <label>Instrument</label><span>{{ s.instrumentType }} {{ s.instrumentId }}</span>
</div> </div>
<div>
<label>Branch</label><span class="mono">{{ s.branchCode }} {{ branchNameFor(s.branchCode) }}</span>
</div>
</div> </div>
<div class="str-narrative"> <div class="str-narrative">
<label>Narrative</label> <label>Narrative</label>
@@ -752,17 +665,198 @@
</label> </label>
<p>{{ poFindingLabel(s.poFinding) }}</p> <p>{{ poFindingLabel(s.poFinding) }}</p>
</div> </div>
} @else { }
<!-- 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'"> <div class="str-po-action" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<label>Principal Officer investigation — is the source of funds legitimate?</label> <label>Principal Officer investigation — is the source of funds legitimate?</label>
<div class="fiu-action-group"> <button class="fiu-btn fiu-btn-xml" (click)="investigateLocalStr(s)">
<button class="fiu-btn fiu-btn-clean" (click)="concludeLocalStr(s, 'CLEAN')"> <fa-icon icon="search"></fa-icon> Investigate this case
Clean — close case </button>
</button> </div>
<button class="fiu-btn fiu-btn-xml" (click)="concludeLocalStr(s, 'SUSPICIOUS')"> }
Suspicious — file STR with FIU-IND
<!-- 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> </button>
</div> </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> </div>
@@ -871,8 +965,8 @@
<div class="fiu-form-title"> <div class="fiu-form-title">
<h3>FINnet 2.0 Submission Files</h3> <h3>FINnet 2.0 Submission Files</h3>
<p> <p>
6 CSV files in FIU-IND prescribed schema, plus STR XML. Counts reflect any filters set on the CTR and 6 CSV files in FIU-IND prescribed schema, plus STR XML. Counts reflect any filters set on the CTR and STR
STR tabs, so every file in the bundle covers the same transactions. tabs, so every file in the bundle covers the same transactions.
</p> </p>
</div> </div>
<button class="fiu-btn fiu-btn-xls" (click)="exportAll()"> <button class="fiu-btn fiu-btn-xls" (click)="exportAll()">
+497
View File
@@ -386,6 +386,78 @@
align-self: end; 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 { .fiu-link-btn {
display: inline-block; display: inline-block;
margin-top: 3px; margin-top: 3px;
@@ -708,6 +780,11 @@
color: var(--f-amber); color: var(--f-amber);
} }
&.st-awaiting-filing {
background: #ffedd5;
color: #9a3412;
}
&.st-filed { &.st-filed {
background: var(--f-green-l); background: var(--f-green-l);
color: var(--f-green); color: var(--f-green);
@@ -1306,6 +1383,128 @@
} }
} }
/* 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 */ /* Derived verdict banner */
.verdict { .verdict {
display: flex; display: flex;
@@ -1350,3 +1549,301 @@
color: var(--f-g500); 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;
}
+588 -79
View File
@@ -13,9 +13,14 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { AmlService } from 'app/aml/aml.service'; import { AmlService } from 'app/aml/aml.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service'; import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { import {
BRANCHES,
BranchMaster,
CardTransaction,
CtrTransaction, CtrTransaction,
CTR_TXNS, CTR_TXNS,
TC1_TXNS,
FIU_BANK, FIU_BANK,
FIU_BRANCH_CODE_TO_CANONICAL,
KC1_INDIVIDUALS, KC1_INDIVIDUALS,
KC2_ENTITIES, KC2_ENTITIES,
KycEntity, KycEntity,
@@ -26,6 +31,90 @@ import {
STR_TXNS STR_TXNS
} from './fiu-data'; } from './fiu-data';
/** A FINnet file rendered as a grid — the same rows that go into the CSV. */
export interface FinnetTable {
headers: string[];
rows: string[][];
}
export interface FinnetFile {
key: string;
/** File name without the extension, as FIU-IND names it. */
name: string;
/** Position in the prescribed submission order. */
step: number;
title: string;
purpose: string;
table: FinnetTable;
}
/**
* The five reports a reporting entity owes FIU-IND under the PMLA, with the
* trigger for each. All of them are filed electronically through FINnet 2.0.
*/
export interface FiuObligation {
code: string;
name: string;
trigger: string;
deadline: string;
icon: string;
/** Whether this module currently files the report or only tracks the rule. */
handled: boolean;
note: string;
}
export const FIU_OBLIGATIONS: FiuObligation[] = [
{
code: 'CTR',
name: 'Cash Transaction Report',
trigger:
'Cash transactions exceeding ₹10,00,000 in a calendar month — a single transaction or a series of connected ones.',
deadline: 'By the 15th of the following month',
icon: 'coins',
handled: true,
note: 'Filed from the CTR tab as the six-file FINnet bundle.'
},
{
code: 'STR',
name: 'Suspicious Transaction Report',
trigger:
'Any transaction that gives rise to a reasonable ground of suspicion of money laundering or a related offence — regardless of amount, and whether or not it was completed.',
deadline: 'Within 7 working days of forming the suspicion',
icon: 'exclamation-triangle',
handled: true,
note: 'Filed from the STR tab as FINnet STR XML.'
},
{
code: 'CBWTR',
name: 'Cross-Border Wire Transfer Report',
trigger:
'Cross-border wire transfers exceeding ₹5,00,000, where the origin or destination of the funds is outside India.',
deadline: 'By the 15th of the following month',
icon: 'globe',
handled: false,
note: 'Not applicable — this bank holds no AD licence and books no cross-border wires.'
},
{
code: 'CCR',
name: 'Counterfeit Currency Report',
trigger:
'Any forged or counterfeit currency note detected, at the counter or in a remittance. No monetary threshold applies.',
deadline: 'By the 15th of the following month',
icon: 'money-bill-wave',
handled: false,
note: 'Raised from the branch counterfeit-note register; not yet wired into this module.'
},
{
code: 'NTR',
name: 'Non-Profit Organisation Transaction Report',
trigger: 'Receipts by a non-profit organisation exceeding ₹10,00,000, or its equivalent in foreign currency.',
deadline: 'By the 15th of the following month',
icon: 'hand-holding-heart',
handled: false,
note: 'Depends on NPO flagging in the customer master; not yet wired into this module.'
}
];
@Component({ @Component({
selector: 'mifosx-fiu', selector: 'mifosx-fiu',
templateUrl: './fiu.component.html', templateUrl: './fiu.component.html',
@@ -45,12 +134,29 @@ export class FiuComponent implements OnInit {
/** Active tab */ /** Active tab */
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard'; activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
/** The five PMLA reporting obligations, shown on the FIU landing tab. */
readonly obligations = FIU_OBLIGATIONS;
bank = FIU_BANK; bank = FIU_BANK;
ctrTxns: CtrTransaction[] = CTR_TXNS; ctrTxns: CtrTransaction[] = CTR_TXNS;
tc1Txns: CardTransaction[] = TC1_TXNS;
strTxns: StrTransaction[] = STR_TXNS; strTxns: StrTransaction[] = STR_TXNS;
kc1: KycIndividual[] = KC1_INDIVIDUALS; kc1: KycIndividual[] = KC1_INDIVIDUALS;
kc2: KycEntity[] = KC2_ENTITIES; kc2: KycEntity[] = KC2_ENTITIES;
// -------------------- Head Office vs Branch scope --------------------
// CTR/STR filing itself is a bank-wide Principal-Officer function (one filing for the whole
// bank, not per branch), but the underlying cash transactions a branch's own staff work
// (evidence capture, referral) are still branch-attributable — scoped the same way every other
// report in the app splits Head Office (sees everything, can drill into one branch) vs a
// branch-level login (locked to its own branch).
readonly branches = BRANCHES;
isHeadOffice = false;
currentOfficeName = 'Branch Office';
private ownBranchCode: string | null = null;
/** '' = All Branches (Head Office consolidated view); otherwise a BranchMaster.code. */
branchFilter = '';
/** CTR filters */ /** CTR filters */
ctrFilterMode: '' | 'Deposit' | 'Withdrawal' = ''; ctrFilterMode: '' | 'Deposit' | 'Withdrawal' = '';
ctrFilterAccount = ''; ctrFilterAccount = '';
@@ -135,6 +241,54 @@ export class FiuComponent implements OnInit {
/** null = not yet answered, true = genuine, false = not satisfied. */ /** null = not yet answered, true = genuine, false = not satisfied. */
strFindings: Record<string, boolean | null> = {}; strFindings: Record<string, boolean | null> = {};
// ── Investigation of a CTR-referred STR case ───────────────────────────────
/** The local STR card currently under investigation, if any. */
investigatingStr: StrTransaction | null = null;
/** Documents the Principal Officer can call for to evidence the source of funds. */
readonly evidenceDocTypes: string[] = [
'Sale deed / agreement for immovable property',
'Salary slips and Form 16',
'Audited financials / ITR of the business',
'Loan sanction or disbursement letter',
'Gift deed with donor PAN and relationship proof',
'Maturity advice of a deposit or insurance policy',
'Sale bill for gold, vehicle or other asset',
'Agricultural income certificate / mandi receipts',
'No document produced by the customer'
];
evidenceDocType = '';
evidenceReceived: 'YES' | 'PARTIAL' | 'NO' | '' = '';
evidenceReference = '';
customerExplanation = '';
/** Opens the investigation panel for a CTR-referred case with a clean slate. */
investigateLocalStr(rec: StrTransaction): void {
this.investigatingStr = rec;
this.selectedStrCase = null;
this.resetChecklist();
this.evidenceDocType = '';
this.evidenceReceived = '';
this.evidenceReference = '';
this.customerExplanation = '';
this.concludeInvestigationNotes = '';
this.concludeSourceOfFundsNotes = '';
}
closeLocalStrInvestigation(): void {
this.investigatingStr = null;
}
/** Documentary evidence is a precondition for clearing a case, not an optional note. */
get evidenceRecorded(): boolean {
return !!this.evidenceDocType && !!this.evidenceReceived;
}
get canConcludeLocal(): boolean {
return this.evidenceRecorded && this.allAnswered && !!this.customerExplanation.trim();
}
private resetChecklist(): void { private resetChecklist(): void {
this.strFindings = {}; this.strFindings = {};
for (const item of this.strChecklist) { for (const item of this.strChecklist) {
@@ -175,6 +329,18 @@ export class FiuComponent implements OnInit {
} }
ngOnInit(): void { ngOnInit(): void {
const creds = this.authenticationService.getCredentials();
this.isHeadOffice = this.authenticationService.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;
}
this.loadAmlAlerts(); this.loadAmlAlerts();
this.loadAmlCtrReports(); this.loadAmlCtrReports();
this.loadAmlStrCases(); this.loadAmlStrCases();
@@ -202,14 +368,51 @@ export class FiuComponent implements OnInit {
} }
} }
whitelistCtrTxn(txn: CtrTransaction): void { // ── CTR-side evidence capture ──────────────────────────────────────────────
txn.complianceStatus = 'WHITELISTED'; /** Transaction whose source-of-funds evidence is being recorded, if any. */
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns)); evidencingTxn: CtrTransaction | null = null;
this.toast(`Transaction for ${txn.customerName} whitelisted — stays in the CTR report only`); ctrEvidenceDocType = '';
ctrEvidenceRef = '';
startCtrEvidence(txn: CtrTransaction): void {
this.evidencingTxn = txn;
this.ctrEvidenceDocType = '';
this.ctrEvidenceRef = '';
} }
flagCtrTxnSuspicious(txn: CtrTransaction): void { cancelCtrEvidence(): void {
txn.complianceStatus = 'SUSPICIOUS'; this.evidencingTxn = null;
}
/**
* Records that the source of funds was evidenced at the counter. This is a
* documentation step, not a suspicion verdict — the transaction is reported in
* the CTR either way; recording evidence simply means no STR referral is needed.
*/
confirmCtrEvidence(): void {
const txn = this.evidencingTxn;
if (!txn) {
return;
}
if (!this.ctrEvidenceDocType) {
this.toast('Select the document that evidences the source of funds');
return;
}
txn.complianceStatus = 'EVIDENCED';
txn.evidenceDocType = this.ctrEvidenceDocType;
txn.evidenceReference = this.ctrEvidenceRef.trim();
txn.evidencedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-');
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
this.evidencingTxn = null;
this.toast(`${txn.customerName} — evidence recorded. Reported in CTR only, no STR referral.`);
}
/**
* Referral to STR. Used when the source of funds could not be evidenced; the
* Principal Officer then investigates and decides whether to file with FIU-IND.
*/
referCtrTxnToStr(txn: CtrTransaction): void {
txn.complianceStatus = 'REFERRED';
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns)); localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
const newRef = Math.max(100, ...this.strTxns.map((s) => s.reportRefNo)) + 1; const newRef = Math.max(100, ...this.strTxns.map((s) => s.reportRefNo)) + 1;
@@ -222,34 +425,112 @@ export class FiuComponent implements OnInit {
riskCategory: 'unusual-pattern', riskCategory: 'unusual-pattern',
narrative: narrative:
`Cash ${txn.depositOrWithdrawal.toLowerCase()} of ₹${txn.txnAmount.toLocaleString('en-IN')} breached the ` + `Cash ${txn.depositOrWithdrawal.toLowerCase()} of ₹${txn.txnAmount.toLocaleString('en-IN')} breached the ` +
`₹${this.bank.cashThreshold.toLocaleString('en-IN')} CTR limit and was flagged as suspicious by the AML Officer. ` + `₹${this.bank.cashThreshold.toLocaleString('en-IN')} CTR limit and genuine evidence of the source of funds ` +
`Referred to the Principal Officer to establish whether the source of funds is legitimate or linked to terror financing.`, `could not be established at the branch. Referred to the Principal Officer for investigation.`,
status: 'po-review', status: 'po-review',
poFinding: undefined poFinding: undefined
}; };
this.strTxns.unshift(newStrCase); this.strTxns.unshift(newStrCase);
localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns)); localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns));
this.toast(`STR-${newRef} opened for ${txn.customerName} — awaiting Principal Officer investigation`); this.toast(`${txn.customerName} referred to STR-${newRef} — awaiting Principal Officer investigation`);
} }
/** Principal Officer closes an STR case as legitimate, or confirms it and files an STR. */ /**
concludeLocalStr(rec: StrTransaction, finding: PoFinding): void { * Concludes a CTR-referred case. The verdict follows the evidence and the
* verification checklist rather than a free choice, and the full trail is written
* into the case remarks so the filing carries its own justification.
*/
concludeLocalStr(): void {
const rec = this.investigatingStr;
if (!rec) {
return;
}
if (!this.evidenceRecorded) {
this.toast('Record which document was called for and whether it was produced');
return;
}
if (!this.customerExplanation.trim()) {
this.toast('Record the explanation the customer gave for the source of funds');
return;
}
if (!this.allAnswered) {
this.toast(`Answer all verification points — ${this.strChecklist.length - this.answeredCount} still pending`);
return;
}
const finding: PoFinding = this.derivedConclusion === 'SUSPICIOUS' ? 'SUSPICIOUS' : 'CLEAN';
const failed = this.failedFindings;
const evidenceLine =
`Document called for: ${this.evidenceDocType}. Produced: ${this.evidenceReceived}` +
(this.evidenceReference.trim() ? ` (ref ${this.evidenceReference.trim()})` : '') +
'.';
const explanationLine = `Customer's explanation: ${this.customerExplanation.trim()}`;
rec.poFinding = finding; rec.poFinding = finding;
if (finding === 'CLEAN') { if (finding === 'CLEAN') {
rec.status = 'rejected'; rec.status = 'rejected';
rec.poRemarks = rec.poRemarks =
'Source-of-funds documentation reviewed and verified by the Principal Officer. No link to terror financing or ' + `${evidenceLine} ${explanationLine} All ${this.strChecklist.length} verification points found genuine, ` +
'any other predicate offence. Case closed — no STR filed with FIU-IND.'; `so the source of funds is established. Case closed — the transaction remains in the CTR report and no STR ` +
this.toast(`STR-${rec.reportRefNo} closed as clean — no STR filed`); `is filed with FIU-IND.`;
// The originating CTR row must stop advertising a live STR once cleared.
const origin = this.ctrTxns.find((t) => t.txnId === rec.txnId);
if (origin) {
origin.complianceStatus = 'EVIDENCED';
origin.evidenceDocType = this.evidenceDocType;
origin.evidenceReference = this.evidenceReference.trim();
origin.evidencedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-');
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
}
this.toast(`STR-${rec.reportRefNo} closed as clean — CTR only, no STR filed`);
} else { } else {
rec.status = 'filed'; // Concluding suspicious does not file anything — the STR still has to be
// submitted on the FINnet portal and its acknowledgement recorded.
rec.status = 'awaiting-filing';
rec.poRemarks = rec.poRemarks =
'Principal Officer investigation could not establish a legitimate source of funds. STR filed with FIU-IND under ' + `${evidenceLine} ${explanationLine} ${failed.length} of ${this.strChecklist.length} verification points not ` +
'PMLA s.12 within the 7-day window; account placed under enhanced monitoring and reported to the Nodal Officer.'; `satisfied: ${failed.map((f) => f.label).join('; ')}. Source of funds not established — STR to be filed with ` +
this.toast(`STR-${rec.reportRefNo} confirmed suspicious — filed with FIU-IND`); `FIU-IND under PMLA s.12 within 7 days and the account placed under enhanced monitoring.`;
this.toast(`STR-${rec.reportRefNo} concluded suspicious — now awaiting submission to FIU-IND`);
} }
localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns)); localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns));
this.investigatingStr = null;
}
/** Acknowledgement number captured against a case awaiting submission. */
localFileRefs: Record<number, string> = {};
/**
* Records that the STR was actually submitted on the FINnet portal. Only this
* step moves a case to "filed" and counts it in the filed KPI.
*/
fileLocalStr(rec: StrTransaction): void {
const ref = (this.localFileRefs[rec.reportRefNo] || '').trim();
if (!ref) {
this.toast('Enter the FIU-IND acknowledgement number received on the FINnet portal');
return;
}
rec.status = 'filed';
rec.fiuReferenceNo = ref;
rec.filedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-');
rec.poRemarks = `${rec.poRemarks ?? ''} Submitted to FIU-IND on ${rec.filedOn}; acknowledgement ${ref}.`.trim();
localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns));
this.toast(`STR-${rec.reportRefNo} filed with FIU-IND — acknowledgement ${ref}`);
}
/** Generates the FINnet STR XML for one case, ready to upload to the portal. */
exportSingleStrXml(rec: StrTransaction): void {
const saved = this.strFilterStatus;
const savedRisk = this.strFilterRisk;
// Temporarily narrow the export to this case only, then restore the filters.
this.strFilterStatus = '';
this.strFilterRisk = '';
const only = [rec];
this.writeStrXml(only, `STR-${rec.reportRefNo}-${rec.customerName ?? 'case'}.xml`);
this.strFilterStatus = saved;
this.strFilterRisk = savedRisk;
this.toast(`STR-${rec.reportRefNo} XML generated — upload it on the FINnet portal`);
} }
poFindingLabel(finding: PoFinding): string { poFindingLabel(finding: PoFinding): string {
@@ -363,12 +644,7 @@ export class FiuComponent implements OnInit {
: `${this.concludeInvestigationNotes.trim()}\n\nAll ${this.strChecklist.length} verification points found genuine.`; : `${this.concludeInvestigationNotes.trim()}\n\nAll ${this.strChecklist.length} verification points found genuine.`;
this.amlService this.amlService
.concludeStrCase( .concludeStrCase(this.selectedStrCase.id, this.concludeConclusion, notes, this.concludeSourceOfFundsNotes)
this.selectedStrCase.id,
this.concludeConclusion,
notes,
this.concludeSourceOfFundsNotes
)
.subscribe({ .subscribe({
next: (updated) => { next: (updated) => {
this.toast( this.toast(
@@ -406,16 +682,16 @@ export class FiuComponent implements OnInit {
} }
/** /**
* Where a live AML alert will end up once triaged — drives the "Routes To" column. * Where a live AML alert stands — drives the "Routes To" column.
* `isCtrRule` distinguishes mechanical CTR-threshold alerts (whitelisting just keeps them * Whitelisting is an STR-side decision, so a CTR alert can only be pending or
* in the CTR file) from behavioral alerts (whitelisting clears them with no report at all). * referred onward. Historic WHITELISTED rows are still rendered for the record.
*/ */
alertRouteLabel(status: string, isCtrRule: boolean = true): string { alertRouteLabel(status: string, isCtrRule: boolean = true): string {
switch (status) { switch (status) {
case 'WHITELISTED': case 'WHITELISTED':
return isCtrRule ? 'CTR only' : 'Cleared'; return isCtrRule ? 'CTR only' : 'Cleared';
case 'ESCALATED': case 'ESCALATED':
return '→ STR'; return isCtrRule ? 'Referred to STR' : '→ STR';
default: default:
return 'Pending'; return 'Pending';
} }
@@ -450,27 +726,27 @@ export class FiuComponent implements OnInit {
this.resetStrFilters(); this.resetStrFilters();
this.highlightedStrRef = match.reportRefNo; this.highlightedStrRef = match.reportRefNo;
setTimeout(() => { setTimeout(() => {
document document.getElementById(`str-card-${match.reportRefNo}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
.getElementById(`str-card-${match.reportRefNo}`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}); });
} }
// -------------------- KPIs -------------------- // -------------------- KPIs --------------------
get totalReports(): number { get totalReports(): number {
return new Set(this.ctrTxns.map((t) => t.reportRefNo)).size; return new Set(this.scopedCtrTxns.map((t) => t.reportRefNo)).size;
} }
get totalCtrTxns(): number { get totalCtrTxns(): number {
return this.ctrTxns.length; return this.scopedCtrTxns.length;
} }
get totalCtrAmount(): number { get totalCtrAmount(): number {
return this.ctrTxns.reduce((s, t) => s + t.txnAmount, 0); return this.scopedCtrTxns.reduce((s, t) => s + t.txnAmount, 0);
} }
get pendingStr(): number { get pendingStr(): number {
return this.strTxns.filter((s) => s.status === 'draft' || s.status === 'po-review').length; return this.scopedStrTxns.filter(
(s) => s.status === 'draft' || s.status === 'po-review' || s.status === 'awaiting-filing'
).length;
} }
get filedStr(): number { get filedStr(): number {
return this.strTxns.filter((s) => s.status === 'filed').length; return this.scopedStrTxns.filter((s) => s.status === 'filed').length;
} }
// ── Live STR case counts (backend `amlStrCases`) — these are the cases the // ── Live STR case counts (backend `amlStrCases`) — these are the cases the
@@ -496,19 +772,65 @@ export class FiuComponent implements OnInit {
} }
get ctrAlerts(): any[] { get ctrAlerts(): any[] {
const ctrRuleCodes = ['CTR_THRESHOLD_10L', 'STRUCTURED_CTR_10L_MONTHLY', 'LARGE_CASH_TXN_25L']; const ctrRuleCodes = [
'CTR_THRESHOLD_10L',
'STRUCTURED_CTR_10L_MONTHLY',
'LARGE_CASH_TXN_25L'
];
return this.amlAlerts.filter((a) => ctrRuleCodes.includes(a.ruleCode)); return this.amlAlerts.filter((a) => ctrRuleCodes.includes(a.ruleCode));
} }
get amlSuspiciousAlerts(): any[] { get amlSuspiciousAlerts(): any[] {
const ctrRuleCodes = ['CTR_THRESHOLD_10L', 'STRUCTURED_CTR_10L_MONTHLY', 'LARGE_CASH_TXN_25L']; const ctrRuleCodes = [
'CTR_THRESHOLD_10L',
'STRUCTURED_CTR_10L_MONTHLY',
'LARGE_CASH_TXN_25L'
];
return this.amlAlerts.filter((a) => !ctrRuleCodes.includes(a.ruleCode)); return this.amlAlerts.filter((a) => !ctrRuleCodes.includes(a.ruleCode));
} }
// -------------------- Head Office vs Branch scope --------------------
/** Maps a FINnet-style internal branch code to the app-wide canonical branch code. */
private canonicalBranchOf(branchCode: string): string | undefined {
return FIU_BRANCH_CODE_TO_CANONICAL[branchCode];
}
/** Branch name for a row's raw FINnet branch code, for display alongside the code itself. */
branchNameFor(branchCode: string): string {
const canonical = this.canonicalBranchOf(branchCode);
return this.branches.find((b) => b.code === canonical)?.name || '';
}
setBranch(code: string): void {
this.branchFilter = code;
}
/** 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 scopedCtrTxns(): CtrTransaction[] {
return this.branchFilter
? this.ctrTxns.filter((t) => this.canonicalBranchOf(t.branchCode) === this.branchFilter)
: this.ctrTxns;
}
get scopedStrTxns(): StrTransaction[] {
return this.branchFilter
? this.strTxns.filter((s) => this.canonicalBranchOf(s.branchCode) === this.branchFilter)
: this.strTxns;
}
// -------------------- Filtered lists -------------------- // -------------------- Filtered lists --------------------
get filteredCtr(): CtrTransaction[] { get filteredCtr(): CtrTransaction[] {
const acc = this.ctrFilterAccount.trim(); const acc = this.ctrFilterAccount.trim();
return this.ctrTxns.filter((t) => { return this.scopedCtrTxns.filter((t) => {
if (this.ctrFilterMode && t.depositOrWithdrawal !== this.ctrFilterMode) return false; if (this.ctrFilterMode && t.depositOrWithdrawal !== this.ctrFilterMode) return false;
if (acc && !t.accountNumber.includes(acc)) return false; if (acc && !t.accountNumber.includes(acc)) return false;
if (this.ctrFilterMinAmount > 0 && t.txnAmount < this.ctrFilterMinAmount) return false; if (this.ctrFilterMinAmount > 0 && t.txnAmount < this.ctrFilterMinAmount) return false;
@@ -516,8 +838,19 @@ export class FiuComponent implements OnInit {
}); });
} }
/** ATM / card legs under the same scope controls, so TC1 and TC2 stay in step. */
get filteredTc1(): CardTransaction[] {
const acc = this.ctrFilterAccount.trim();
return this.tc1Txns.filter((c) => {
if (this.ctrFilterMode && c.depositOrWithdrawal !== this.ctrFilterMode) return false;
if (acc && !c.accountNumber.includes(acc)) return false;
if (this.ctrFilterMinAmount > 0 && c.txnAmount < this.ctrFilterMinAmount) return false;
return true;
});
}
get filteredStr(): StrTransaction[] { get filteredStr(): StrTransaction[] {
return this.strTxns.filter((s) => { return this.scopedStrTxns.filter((s) => {
if (this.strFilterStatus && s.status !== this.strFilterStatus) return false; if (this.strFilterStatus && s.status !== this.strFilterStatus) return false;
if (this.strFilterRisk && s.riskCategory !== this.strFilterRisk) return false; if (this.strFilterRisk && s.riskCategory !== this.strFilterRisk) return false;
return true; return true;
@@ -544,7 +877,14 @@ export class FiuComponent implements OnInit {
// -------------------- Helpers -------------------- // -------------------- Helpers --------------------
statusLabel(s: StrStatus): string { statusLabel(s: StrStatus): string {
return s === 'draft' ? 'Draft' : s === 'po-review' ? 'PO Review' : s === 'filed' ? 'Filed' : 'Rejected'; const map: Record<StrStatus, string> = {
draft: 'Draft',
'po-review': 'PO Review',
'awaiting-filing': 'Awaiting Filing',
filed: 'Filed',
rejected: 'Closed — Clean'
};
return map[s] ?? s;
} }
riskLabel(c: StrTransaction['riskCategory']): string { riskLabel(c: StrTransaction['riskCategory']): string {
const map: Record<StrTransaction['riskCategory'], string> = { const map: Record<StrTransaction['riskCategory'], string> = {
@@ -558,9 +898,14 @@ export class FiuComponent implements OnInit {
return map[c]; return map[c];
} }
// -------------------- Exports -------------------- // -------------------- FINnet file builders --------------------
/** TC2 — Branch / cash transaction CSV */ /**
exportTc2(): void { * Each FINnet file is built once, here, and used for both the on-screen
* preview and the CSV download. Keeping a single builder per file is what
* guarantees the branch sees on screen exactly what is submitted — the two
* used to be written out separately, which let them drift.
*/
private buildTc2(): FinnetTable {
const headers = [ const headers = [
'Report Type', 'Report Type',
'Report Reference Number', 'Report Reference Number',
@@ -579,7 +924,7 @@ export class FiuComponent implements OnInit {
'Third Party PAN (Non-Customer)', 'Third Party PAN (Non-Customer)',
'Declaration (If Third Party PAN (Non-Customer) is not available)' 'Declaration (If Third Party PAN (Non-Customer) is not available)'
]; ];
const rows = [headers]; const rows: string[][] = [];
for (const t of this.filteredCtr) { for (const t of this.filteredCtr) {
rows.push([ rows.push([
t.reportType, t.reportType,
@@ -600,12 +945,11 @@ export class FiuComponent implements OnInit {
t.declaration t.declaration
]); ]);
} }
this.saveCsv(rows, 'TC2.csv'); return { headers, rows };
this.toast(`TC2 exported (${this.filteredCtr.length} rows)`);
} }
/** TC1 header-only template (UI doesn't track ATM/card txns) */ /** TC1 — ATM / card transactions. Header-only: this bank books no card txns. */
exportTc1(): void { private buildTc1(): FinnetTable {
const headers = [ const headers = [
'Relationship Flag', 'Relationship Flag',
'Transaction Status', 'Transaction Status',
@@ -626,12 +970,34 @@ export class FiuComponent implements OnInit {
'Branch Code of Account', 'Branch Code of Account',
'Report Type' 'Report Type'
]; ];
this.saveCsv([headers], 'TC1.csv'); const rows: string[][] = [];
this.toast('TC1 template exported (header only)'); for (const c of this.filteredTc1) {
rows.push([
c.relationshipFlag,
c.txnStatus,
c.txnId.toString(),
c.txnDate.replace(/-/g, '/'),
c.txnTime,
c.depositOrWithdrawal,
c.txnAmount.toString(),
c.accountNumber,
c.accountType,
c.cardType,
c.cardNumber,
c.cardIssuingCountry,
c.cardIssuingBank,
c.reportRefNo.toString(),
c.switchTxnId,
c.atmId,
c.branchCode,
c.reportType
]);
}
return { headers, rows };
} }
/** KC1 — Individual customers */ /** KC1 — Individual customers */
exportKc1(): void { private buildKc1(): FinnetTable {
const headers = [ const headers = [
'Report Reference Number', 'Report Reference Number',
'UCIC', 'UCIC',
@@ -678,7 +1044,7 @@ export class FiuComponent implements OnInit {
'Other Identifier Number', 'Other Identifier Number',
'Primary Entity' 'Primary Entity'
]; ];
const rows = [headers]; const rows: string[][] = [];
for (const k of this.kc1) { for (const k of this.kc1) {
rows.push([ rows.push([
k.reportRefNo.toString(), k.reportRefNo.toString(),
@@ -727,12 +1093,11 @@ export class FiuComponent implements OnInit {
'Yes' 'Yes'
]); ]);
} }
this.saveCsv(rows, 'KC1.csv'); return { headers, rows };
this.toast(`KC1 exported (${this.kc1.length} individuals)`);
} }
/** KC2 — Entity customers */ /** KC2 — Entity customers */
exportKc2(): void { private buildKc2(): FinnetTable {
const headers = [ const headers = [
'Report Reference Number', 'Report Reference Number',
'UCIC', 'UCIC',
@@ -767,7 +1132,7 @@ export class FiuComponent implements OnInit {
'UCIC of Owner of Sole Proprietor Firm', 'UCIC of Owner of Sole Proprietor Firm',
'Name of Owner of Sole Proprietor Firm' 'Name of Owner of Sole Proprietor Firm'
]; ];
const rows = [headers]; const rows: string[][] = [];
for (const k of this.kc2) { for (const k of this.kc2) {
rows.push([ rows.push([
k.reportRefNo.toString(), k.reportRefNo.toString(),
@@ -804,12 +1169,11 @@ export class FiuComponent implements OnInit {
k.ownerName k.ownerName
]); ]);
} }
this.saveCsv(rows, 'KC2.csv'); return { headers, rows };
this.toast(`KC2 exported (${this.kc2.length} entities)`);
} }
/** Account_Detail.csv — per-report account aggregates (12-month) */ /** Account_Detail — per-report account aggregates (12-month) */
exportAccountDetail(): void { private buildAccountDetail(): FinnetTable {
const headers = [ const headers = [
'Report Type', 'Report Type',
'Report Reference Number', 'Report Reference Number',
@@ -828,7 +1192,7 @@ export class FiuComponent implements OnInit {
'Total Cash Deposit (In last 12 months)', 'Total Cash Deposit (In last 12 months)',
'Total Cash Withdrawal (In last 12 months) Amount' 'Total Cash Withdrawal (In last 12 months) Amount'
]; ];
const rows = [headers]; const rows: string[][] = [];
// Build one aggregate row per (reportRefNo, accountNumber) pair // Build one aggregate row per (reportRefNo, accountNumber) pair
const seen = new Set<string>(); const seen = new Set<string>();
for (const t of this.filteredCtr) { for (const t of this.filteredCtr) {
@@ -866,12 +1230,11 @@ export class FiuComponent implements OnInit {
.toString() .toString()
]); ]);
} }
this.saveCsv(rows, 'Account_Detail.csv'); return { headers, rows };
this.toast(`Account_Detail exported (${rows.length - 1} accounts)`);
} }
/** Account_Person_Relation.csv — account ↔ customer mapping */ /** Account_Person_Relation — account ↔ customer mapping */
exportAccountPersonRelation(): void { private buildAccountPersonRelation(): FinnetTable {
const headers = [ const headers = [
'Report Type', 'Report Type',
'Report Reference Number', 'Report Reference Number',
@@ -882,7 +1245,7 @@ export class FiuComponent implements OnInit {
'Individual / Non-individual', 'Individual / Non-individual',
'Name of Non-customer' 'Name of Non-customer'
]; ];
const rows = [headers]; const rows: string[][] = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const t of this.filteredCtr) { for (const t of this.filteredCtr) {
const key = `${t.reportRefNo}|${t.accountNumber}`; const key = `${t.reportRefNo}|${t.accountNumber}`;
@@ -901,8 +1264,145 @@ export class FiuComponent implements OnInit {
t.relationshipFlag === 'Non-Customer' ? t.customerName || '' : '' t.relationshipFlag === 'Non-Customer' ? t.customerName || '' : ''
]); ]);
} }
this.saveCsv(rows, 'Account_Person_Relation.csv'); return { headers, rows };
this.toast(`Account_Person_Relation exported (${rows.length - 1} rows)`); }
// -------------------- FINnet file sequence --------------------
/**
* The six CTR files in the order FIU-IND expects them: the account is
* described first, then who is behind it, then the transactions, and finally
* the customer cards those transactions belong to.
*/
private get allFinnetFiles(): FinnetFile[] {
return [
{
key: 'account-detail',
name: 'Account_Detail',
step: 1,
title: 'Account Detail',
purpose:
'One row per reported account — status, opening date and the 12-month debit, credit and cash aggregates.',
table: this.buildAccountDetail()
},
{
key: 'account-person-relation',
name: 'Account_Person_Relation',
step: 2,
title: 'Account ↔ Person Relation',
purpose:
'Links each account to the customers behind it, with the relationship type and whether they are an individual or an entity.',
table: this.buildAccountPersonRelation()
},
{
key: 'tc1',
name: 'TC1',
step: 3,
title: 'TC1 — ATM / Card Transactions',
purpose:
'The card-rail leg of the same CTR: ATM withdrawals, recycler deposits and declined attempts. An ATM cash withdrawal is still cash, so it counts towards the customer’s monthly aggregate alongside TC2.',
table: this.buildTc1()
},
{
key: 'tc2',
name: 'TC2',
step: 4,
title: 'TC2 — Branch / Cash Transactions',
purpose: 'The transaction detail itself — every cash transaction that put the account over the CTR threshold.',
table: this.buildTc2()
},
{
key: 'kc1',
name: 'KC1',
step: 5,
title: 'KC1 — Individual Customer Cards',
purpose: 'Full KYC card for every individual named in the report: identifiers, address, income, risk level.',
table: this.buildKc1()
},
{
key: 'kc2',
name: 'KC2',
step: 6,
title: 'KC2 — Entity Customer Cards',
purpose: 'The same for non-individuals: firms, HUFs and societies, with GSTIN and sole-proprietor ownership.',
table: this.buildKc2()
}
];
}
/**
* The files walked through in the CTR tab: account, then who is behind it,
* then the two transaction files. The customer cards are excluded here and
* live on the Customer Cards tab instead, though they still travel with the
* full bundle.
*/
get finnetSequence(): FinnetFile[] {
return this.allFinnetFiles.filter((f) => f.key !== 'kc1' && f.key !== 'kc2');
}
/** Which file in the sequence is open in the CTR tab. */
activeFinnetFile = 'account-detail';
get openFinnetFile(): FinnetFile {
return this.finnetSequence.find((f) => f.key === this.activeFinnetFile) ?? this.finnetSequence[0];
}
showFinnetFile(key: string): void {
this.activeFinnetFile = key;
}
/** Downloads whichever file is currently previewed, so the button matches the table. */
downloadOpenFinnetFile(): void {
const f = this.openFinnetFile;
this.saveCsv(
[
f.table.headers,
...f.table.rows
],
`${f.name}.csv`
);
this.toast(`${f.name} exported (${f.table.rows.length} rows)`);
}
// -------------------- Exports --------------------
private saveTable(file: FinnetFile): void {
this.saveCsv(
[
file.table.headers,
...file.table.rows
],
`${file.name}.csv`
);
this.toast(`${file.name} exported (${file.table.rows.length} rows)`);
}
private fileByKey(key: string): FinnetFile {
return this.allFinnetFiles.find((f) => f.key === key)!;
}
exportTc1(): void {
this.saveTable(this.fileByKey('tc1'));
}
exportTc2(): void {
this.saveTable(this.fileByKey('tc2'));
}
exportKc1(): void {
this.saveTable(this.fileByKey('kc1'));
}
exportKc2(): void {
this.saveTable(this.fileByKey('kc2'));
}
exportAccountDetail(): void {
this.saveTable(this.fileByKey('account-detail'));
}
exportAccountPersonRelation(): void {
this.saveTable(this.fileByKey('account-person-relation'));
} }
/** STR XML stub (FINnet 2.0 STR XML) */ /** STR XML stub (FINnet 2.0 STR XML) */
@@ -912,6 +1412,12 @@ export class FiuComponent implements OnInit {
this.toast('No suspect cases to export — nothing matches the current filters'); this.toast('No suspect cases to export — nothing matches the current filters');
return; return;
} }
this.writeStrXml(suspects, `STR-${this.bank.reportingMonth.replace(/\s+/g, '-')}.xml`);
this.toast(`STR XML exported (${suspects.length} suspects)`);
}
/** Serialises a set of suspect cases as FINnet 2.0 STR XML and downloads it. */
private writeStrXml(suspects: StrTransaction[], filename: string): void {
const xml: string[] = []; const xml: string[] = [];
xml.push('<?xml version="1.0" encoding="UTF-8"?>'); xml.push('<?xml version="1.0" encoding="UTF-8"?>');
xml.push(`<STR xmlns="http://fiuind.gov.in/finnet2/str" reportingMonth="${this.bank.reportingMonth}">`); xml.push(`<STR xmlns="http://fiuind.gov.in/finnet2/str" reportingMonth="${this.bank.reportingMonth}">`);
@@ -927,6 +1433,9 @@ export class FiuComponent implements OnInit {
xml.push(` <Detection date="${s.detectedOn}" by="${this.escapeXml(s.flaggedBy)}" />`); xml.push(` <Detection date="${s.detectedOn}" by="${this.escapeXml(s.flaggedBy)}" />`);
xml.push(` <Narrative>${this.escapeXml(s.narrative)}</Narrative>`); xml.push(` <Narrative>${this.escapeXml(s.narrative)}</Narrative>`);
if (s.poRemarks) xml.push(` <PORemarks>${this.escapeXml(s.poRemarks)}</PORemarks>`); if (s.poRemarks) xml.push(` <PORemarks>${this.escapeXml(s.poRemarks)}</PORemarks>`);
if (s.fiuReferenceNo) {
xml.push(` <Acknowledgement ref="${this.escapeXml(s.fiuReferenceNo)}" filedOn="${s.filedOn ?? ''}" />`);
}
xml.push(' </Suspect>'); xml.push(' </Suspect>');
} }
xml.push('</STR>'); xml.push('</STR>');
@@ -934,21 +1443,21 @@ export class FiuComponent implements OnInit {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `STR-${this.bank.reportingMonth.replace(/\s+/g, '-')}.xml`; a.download = filename;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
this.toast(`STR XML exported (${suspects.length} suspects)`);
} }
/** Bundle: the 6 CSVs plus the STR XML — emitted as separate downloads sequentially */ /**
* Bundle: the 6 CSVs plus the STR XML, emitted as separate downloads in the
* order FIU-IND expects them — account, then relation, then TC, then KC.
*/
exportAll(): void { exportAll(): void {
setTimeout(() => this.exportTc1(), 0); // All six travel together — the customer cards are not walked through in the
setTimeout(() => this.exportTc2(), 200); // CTR tab, but a bundle without them would be incomplete.
setTimeout(() => this.exportKc1(), 400); const files = this.allFinnetFiles;
setTimeout(() => this.exportKc2(), 600); files.forEach((f, i) => setTimeout(() => this.saveTable(f), i * 200));
setTimeout(() => this.exportAccountDetail(), 800); setTimeout(() => this.exportStrXml(), files.length * 200);
setTimeout(() => this.exportAccountPersonRelation(), 1000);
setTimeout(() => this.exportStrXml(), 1200);
} }
private escapeXml(s: string): string { private escapeXml(s: string): string {
@@ -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)
};
}
+245 -18
View File
@@ -114,18 +114,84 @@ export const GL_ACCOUNTS: GlAccount[] = [
{ code: '420103', name: "Auditors' 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: '420104', name: 'Rent, Taxes & Lighting', type: 'EXPENSE', group: 'Operating Expenses' },
{ code: '420105', name: 'Depreciation on Bank Property', 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: '430101',
{ code: '430103', name: 'Advertisement & Publicity', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true }, name: 'Printing & Stationery',
{ code: '430104', name: 'Repairs & Maintenance', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true }, type: 'EXPENSE',
{ code: '430105', name: 'Travelling & Conveyance', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true }, group: 'Miscellaneous Expenses',
{ code: '430106', name: 'Computer & Software Expenses', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true }, 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: '430102',
{ code: '430110', name: 'AGM & Meeting Expenses', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true }, 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: '430111', name: 'Electricity Charges', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430112', name: 'Other Miscellaneous Expenses', 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) ────────── // ── Contingent (memoranda, not part of the balance sheet totals) ──────────
{ code: '910101', name: 'Guarantees Issued — Financial', type: 'CONTINGENT', group: 'Guarantees' }, { code: '910101', name: 'Guarantees Issued — Financial', type: 'CONTINGENT', group: 'Guarantees' },
@@ -261,8 +327,20 @@ const VOUCHER_TEMPLATES: {
{ debit: '110101', credit: '320102', narration: 'Locker rent collected', scale: 9_000, frequency: 0.3 }, { 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: '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: '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: '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: '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: '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: '430101', credit: '110101', narration: 'Printing and stationery purchased', scale: 9_400, frequency: 0.3 },
@@ -275,14 +353,32 @@ const VOUCHER_TEMPLATES: {
{ debit: '430109', credit: '110101', narration: 'Bank charges and commission paid', scale: 4_800, frequency: 0.3 }, { 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: '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: '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 } 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. */ /** Head-Office-only vouchers — investments, reserves and centralised charges. */
const HO_TEMPLATES: typeof VOUCHER_TEMPLATES = [ const HO_TEMPLATES: typeof VOUCHER_TEMPLATES = [
{ debit: '120101', credit: '110102', narration: 'Purchase of Govt securities', scale: 2_400_000, frequency: 0.12 }, { 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: '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: '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: '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: '420103', credit: '110101', narration: "Statutory auditors' fees", scale: 45_000, frequency: 0.06 },
@@ -342,7 +438,10 @@ export function glPostings(): GlPosting[] {
let openingDebit = 0; let openingDebit = 0;
let openingCredit = 0; let openingCredit = 0;
for (const [code, amount] of Object.entries(OPENING_BALANCES)) { for (const [
code,
amount
] of Object.entries(OPENING_BALANCES)) {
if (!amount) { if (!amount) {
continue; continue;
} }
@@ -425,7 +524,10 @@ export function glPostings(): GlPosting[] {
for (const branch of GL_BRANCHES) { for (const branch of GL_BRANCHES) {
// Head Office posts fewer counter transactions than the branches. // Head Office posts fewer counter transactions than the branches.
const weight = branch.code === 'HO' ? 0.45 : 1; const weight = branch.code === 'HO' ? 0.45 : 1;
const templates = branch.code === 'HO' ? [...VOUCHER_TEMPLATES, ...HO_TEMPLATES] : VOUCHER_TEMPLATES; const templates = branch.code === 'HO' ? [
...VOUCHER_TEMPLATES,
...HO_TEMPLATES
] : VOUCHER_TEMPLATES;
for (const tpl of templates) { for (const tpl of templates) {
if (rand() > tpl.frequency * weight) { if (rand() > tpl.frequency * weight) {
@@ -487,6 +589,131 @@ export function glPostings(): GlPosting[] {
return 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 { export function glAccount(code: string): GlAccount | undefined {
return GL_ACCOUNTS.find((a) => a.code === code); return GL_ACCOUNTS.find((a) => a.code === code);
} }
+552 -20
View File
@@ -79,17 +79,33 @@
<div class="glr-filter"> <div class="glr-filter">
<label>Branch</label> <label>Branch</label>
<select class="glr-inp" [(ngModel)]="branchCode"> @if (canSelectBranch) {
<option value="">All Branches (Consolidated)</option> <select class="glr-inp" [(ngModel)]="branchCode">
@for (b of branches; track b.code) { <option value="">All Branches (Consolidated)</option>
<option [value]="b.code">{{ b.code }} — {{ b.name }}</option> @for (b of branches; track b.code) {
} <option [value]="b.code">{{ b.code }} — {{ b.name }}</option>
</select> }
</select>
} @else {
<span class="glr-chip" title="Your login is restricted to this branch">
<fa-icon icon="university"></fa-icon> {{ scopeName }}
</span>
}
</div> </div>
<button class="glr-btn" (click)="resetFilters()">Reset</button> <button class="glr-btn" (click)="resetFilters()">Reset</button>
</div> </div>
@if (scopeIsUnresolved) {
<div class="glr-balance glr-balance--bad">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>
Your office <strong>{{ scopeName }}</strong> does not match any branch in the ledger, so no figures can be
shown. Ask an administrator to align your office name with a branch.
</span>
</div>
}
<!-- REPORT SELECTOR --> <!-- REPORT SELECTOR -->
<div class="glr-tabs"> <div class="glr-tabs">
@for (r of reports; track r.code) { @for (r of reports; track r.code) {
@@ -193,7 +209,9 @@
@for (r of pnlExpense; track r.code) { @for (r of pnlExpense; track r.code) {
<tr> <tr>
<td class="mono">{{ r.code }}</td> <td class="mono">{{ r.code }}</td>
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td> <td>
{{ r.name }}<span class="muted"> · {{ r.group }}</span>
</td>
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td> <td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
</tr> </tr>
} @empty { } @empty {
@@ -229,7 +247,9 @@
@for (r of pnlIncome; track r.code) { @for (r of pnlIncome; track r.code) {
<tr> <tr>
<td class="mono">{{ r.code }}</td> <td class="mono">{{ r.code }}</td>
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td> <td>
{{ r.name }}<span class="muted"> · {{ r.group }}</span>
</td>
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td> <td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
</tr> </tr>
} @empty { } @empty {
@@ -281,7 +301,9 @@
@for (r of bsLiabilities; track r.code) { @for (r of bsLiabilities; track r.code) {
<tr> <tr>
<td class="mono">{{ r.code }}</td> <td class="mono">{{ r.code }}</td>
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td> <td>
{{ r.name }}<span class="muted"> · {{ r.group }}</span>
</td>
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td> <td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
</tr> </tr>
} }
@@ -312,7 +334,9 @@
@for (r of bsAssets; track r.code) { @for (r of bsAssets; track r.code) {
<tr> <tr>
<td class="mono">{{ r.code }}</td> <td class="mono">{{ r.code }}</td>
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td> <td>
{{ r.name }}<span class="muted"> · {{ r.group }}</span>
</td>
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td> <td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
</tr> </tr>
} }
@@ -331,11 +355,17 @@
@if (activeReport === 'comparative-balance-sheet') { @if (activeReport === 'comparative-balance-sheet') {
<div class="glr-section"> <div class="glr-section">
<p class="glr-note"> <p class="glr-note">
Compares the closing position as at <strong>{{ range.to }}</strong> against the position immediately before Compares the closing position as at <strong>{{ range.to }}</strong> against the position immediately before this
this period began. Change the filter above to compare a different span. period began. Change the filter above to compare a different span.
</p> </p>
@for (block of [{ title: 'Capital & Liabilities', rows: compLiabilities }, { title: 'Assets', rows: compAssets }]; track block.title) { @for (
block of [
{ title: 'Capital & Liabilities', rows: compLiabilities },
{ title: 'Assets', rows: compAssets }
];
track block.title
) {
<div class="glr-tbl-wrap"> <div class="glr-tbl-wrap">
<div class="glr-tbl-h">{{ block.title }}</div> <div class="glr-tbl-h">{{ block.title }}</div>
<table class="glr-tbl"> <table class="glr-tbl">
@@ -353,7 +383,9 @@
@for (r of block.rows; track r.code) { @for (r of block.rows; track r.code) {
<tr> <tr>
<td class="mono">{{ r.code }}</td> <td class="mono">{{ r.code }}</td>
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td> <td>
{{ r.name }}<span class="muted"> · {{ r.group }}</span>
</td>
<td class="ta-r mono">{{ r.current | number: '1.2-2' }}</td> <td class="ta-r mono">{{ r.current | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ r.previous | number: '1.2-2' }}</td> <td class="ta-r mono">{{ r.previous | number: '1.2-2' }}</td>
<td class="ta-r mono" [class.var-up]="r.variance > 0" [class.var-down]="r.variance < 0"> <td class="ta-r mono" [class.var-up]="r.variance > 0" [class.var-down]="r.variance < 0">
@@ -379,8 +411,8 @@
@if (activeReport === 'contingent-liabilities') { @if (activeReport === 'contingent-liabilities') {
<div class="glr-section"> <div class="glr-section">
<p class="glr-note"> <p class="glr-note">
Off-balance-sheet commitments outstanding as at <strong>{{ range.to }}</strong>. These are memoranda accounts Off-balance-sheet commitments outstanding as at <strong>{{ range.to }}</strong
and are deliberately excluded from the Trial Balance and Balance Sheet totals. >. These are memoranda accounts and are deliberately excluded from the Trial Balance and Balance Sheet totals.
</p> </p>
<div class="glr-tbl-wrap"> <div class="glr-tbl-wrap">
<table class="glr-tbl"> <table class="glr-tbl">
@@ -419,8 +451,8 @@
@if (activeReport === 'gl-wise-dr-cr') { @if (activeReport === 'gl-wise-dr-cr') {
<div class="glr-section"> <div class="glr-section">
<p class="glr-note"> <p class="glr-note">
Debit and credit turnover posted between <strong>{{ rangeLabel }}</strong> for {{ branchLabel }}. Every Debit and credit turnover posted between <strong>{{ rangeLabel }}</strong> for {{ branchLabel }}. Every voucher
voucher is double-entry, so the debit and credit totals below must match. is double-entry, so the debit and credit totals below must match.
</p> </p>
<div class="glr-tbl-wrap"> <div class="glr-tbl-wrap">
<table class="glr-tbl"> <table class="glr-tbl">
@@ -474,8 +506,8 @@
@if (activeReport === 'misc-expense-ledger') { @if (activeReport === 'misc-expense-ledger') {
<div class="glr-section"> <div class="glr-section">
<p class="glr-note"> <p class="glr-note">
Every miscellaneous-expense voucher posted between <strong>{{ rangeLabel }}</strong> for {{ branchLabel }} Every miscellaneous-expense voucher posted between <strong>{{ rangeLabel }}</strong> for
&mdash; {{ miscRows.length | number }} entries. {{ branchLabel }} &mdash; {{ miscRows.length | number }} entries.
</p> </p>
<div class="glr-tbl-wrap glr-tbl-wrap--tall"> <div class="glr-tbl-wrap glr-tbl-wrap--tall">
<table class="glr-tbl"> <table class="glr-tbl">
@@ -518,6 +550,506 @@
</div> </div>
} }
<!-- ══════════════ GL AVERAGE BALANCE ══════════════ -->
@if (activeReport === 'gl-average-balance') {
<div class="glr-section">
<p class="glr-note">
Average of each account's closing balance across every day in
<strong>{{ rangeLabel }}</strong> for {{ branchLabel }}. Banks work from average balances rather than a closing
snapshot for interest, CRR/SLR and profitability, because a single day's figure can be dressed up.
</p>
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
<table class="glr-tbl">
<thead>
<tr>
<th>GL Code</th>
<th>Account</th>
<th>Group</th>
<th class="ta-r">Opening</th>
<th class="ta-r">Closing</th>
<th class="ta-r">Average Daily</th>
<th class="ta-r">Peak</th>
<th class="ta-r">Lowest</th>
<th class="ta-c">Active Days</th>
</tr>
</thead>
<tbody>
@for (r of averageRows; track r.code) {
<tr>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}</td>
<td class="muted">{{ r.group }}</td>
<td class="ta-r mono">{{ r.opening | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ r.closing | number: '1.2-2' }}</td>
<td class="ta-r mono">
<strong>{{ r.average | number: '1.2-2' }}</strong>
</td>
<td class="ta-r mono">{{ r.peak | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ r.lowest | number: '1.2-2' }}</td>
<td class="ta-c mono">{{ r.activeDays }}</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="glr-empty">No ledger activity for this period and branch.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="5">TOTAL ({{ averageRows.length }} accounts)</td>
<td class="ta-r mono">{{ avgTotalAverage | number: '1.2-2' }}</td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
</div>
</div>
}
<!-- ══════════════ GL MAPPING FOR REPORT ══════════════ -->
@if (activeReport === 'gl-mapping') {
<div class="glr-section">
<p class="glr-note">
Which General Ledger accounts feed each RBI return line. Mappings are created on the OSS 1, OSS 2 and OSS 3
screens using <strong>Add / Remove GLs</strong>; this is the consolidated view of them, with the value each
mapping currently produces from the ledger.
</p>
<div class="glr-tbl-wrap">
<div class="glr-tbl-h">Mapped return lines ({{ mappingRows.length }})</div>
<table class="glr-tbl">
<thead>
<tr>
<th>Return</th>
<th>Return line</th>
<th>Mapped GL accounts</th>
<th class="ta-r">Ledger value (&#8377; thousands)</th>
</tr>
</thead>
<tbody>
@for (r of mappingRows; track r.returnCode + r.lineKey) {
<tr>
<td>{{ r.returnLabel }}</td>
<td class="mono">{{ r.lineKey }}</td>
<td>
@for (c of r.codes; track c) {
<span class="glr-gl-chip mono">{{ c }}</span>
}
</td>
<td class="ta-r mono">{{ r.value | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="4" class="glr-empty">
No return lines mapped yet. Open OSS 1, 2 or 3 and use “Add / Remove GLs” to pin accounts to a line.
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
<div class="glr-tbl-h">Unmapped GL accounts ({{ unmappedAccounts.length }}) — not feeding any return line</div>
<table class="glr-tbl">
<thead>
<tr>
<th>GL Code</th>
<th>Account</th>
<th>Group</th>
</tr>
</thead>
<tbody>
@for (a of unmappedAccounts; track a.code) {
<tr>
<td class="mono">{{ a.code }}</td>
<td>{{ a.name }}</td>
<td class="muted">{{ a.group }}</td>
</tr>
} @empty {
<tr>
<td colspan="3" class="glr-empty">Every GL account feeds at least one return line.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- ══════════════ ACCOUNT WISE BALANCE LIST ══════════════ -->
@if (activeReport === 'account-wise-balance') {
<div class="glr-section">
<p class="glr-note">
Closing balance of every account as at <strong>{{ range.to }}</strong
>, broken out by branch — which branch is actually carrying each balance.
</p>
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
<table class="glr-tbl">
<thead>
<tr>
<th>GL Code</th>
<th>Account</th>
<th>Group</th>
@for (c of branchColumns; track c) {
<th class="ta-r">{{ branchName(c) }}</th>
}
<th class="ta-r">Total</th>
</tr>
</thead>
<tbody>
@for (r of accountWiseRows; track r.code) {
<tr>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}</td>
<td class="muted">{{ r.group }}</td>
@for (c of branchColumns; track c) {
<td class="ta-r mono">{{ r.byBranch[c] ? (r.byBranch[c] | number: '1.2-2') : '' }}</td>
}
<td class="ta-r mono">
<strong>{{ r.total | number: '1.2-2' }}</strong>
</td>
</tr>
} @empty {
<tr>
<td [attr.colspan]="branchColumns.length + 4" class="glr-empty">
No balances for this period and branch.
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="3">TOTAL ({{ accountWiseRows.length }} accounts)</td>
@for (c of branchColumns; track c) {
<td class="ta-r mono">{{ branchColumnTotal(c) | number: '1.2-2' }}</td>
}
<td class="ta-r mono">{{ accountWiseGrandTotal | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
}
<!-- ══════════════ DISTRIBUTION OF BALANCE ══════════════ -->
@if (activeReport === 'distribution-of-balance') {
<div class="glr-section">
<p class="glr-note">
Composition of the book as at <strong>{{ range.to }}</strong> for {{ branchLabel }} — how concentrated each side
of the balance sheet is by account group.
</p>
<div class="glr-two-col">
@for (
block of [
{ title: 'Capital & Liabilities', rows: liabilityDistribution, total: liabilityDistTotal },
{ title: 'Assets', rows: assetDistribution, total: assetDistTotal }
];
track block.title
) {
<div class="glr-tbl-wrap">
<div class="glr-tbl-h">{{ block.title }}</div>
<table class="glr-tbl">
<thead>
<tr>
<th>Group</th>
<th class="ta-c">A/cs</th>
<th class="ta-r">Amount (&#8377;)</th>
<th class="ta-r">Share</th>
<th class="glr-bar-col">Distribution</th>
</tr>
</thead>
<tbody>
@for (r of block.rows; track r.group) {
<tr>
<td>{{ r.group }}</td>
<td class="ta-c mono">{{ r.accounts }}</td>
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
<td class="ta-r mono">
<strong>{{ r.share | number: '1.2-2' }}%</strong>
</td>
<td>
<div class="glr-bar">
<div class="glr-bar-fill" [style.width.%]="r.share"></div>
</div>
</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="glr-empty">No balances on this side.</td>
</tr>
}
<tr class="total-row">
<td colspan="2">Total</td>
<td class="ta-r mono">{{ block.total | number: '1.2-2' }}</td>
<td class="ta-r mono">100.00%</td>
<td></td>
</tr>
</tbody>
</table>
</div>
}
</div>
</div>
}
<!-- ══════════════ CLIENT WISE BALANCE ══════════════ -->
@if (activeReport === 'client-wise-balance') {
<div class="glr-section">
<div class="glr-kpis">
<div class="glr-kpi glr-kpi--teal">
<div class="glr-kpi-n">&#8377;{{ clientDeposits | number: '1.2-2' }}</div>
<div class="glr-kpi-l">Customer Deposits</div>
</div>
<div class="glr-kpi glr-kpi--amber">
<div class="glr-kpi-n">&#8377;{{ clientAdvances | number: '1.2-2' }}</div>
<div class="glr-kpi-l">Customer Advances</div>
</div>
<div class="glr-kpi glr-kpi--green">
<div class="glr-kpi-n">{{ clientRows.length | number }}</div>
<div class="glr-kpi-l">Relationships</div>
<div class="glr-kpi-sub">as at {{ range.to }} · {{ branchLabel }}</div>
</div>
</div>
<p class="glr-note">
Customer balances are derived from the General Ledger — each relationship holds a share of its branch's balance
in that head — so this report always reconciles to the GL rather than drifting as a second source.
</p>
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
<table class="glr-tbl">
<thead>
<tr>
<th>CIF</th>
<th>Customer</th>
<th>Branch</th>
<th>Account No</th>
<th>Product</th>
<th class="ta-c">Side</th>
<th class="ta-r">Balance (&#8377;)</th>
</tr>
</thead>
<tbody>
@for (r of clientRows; track r.cif) {
<tr>
<td class="mono">{{ r.cif }}</td>
<td>{{ r.name }}</td>
<td>{{ branchName(r.branchCode) }}</td>
<td class="mono">{{ r.accountNo }}</td>
<td class="muted">{{ r.product }}</td>
<td class="ta-c">
<span
class="type-pill"
[class.type-liability]="r.side === 'DEPOSIT'"
[class.type-asset]="r.side === 'ADVANCE'"
>
{{ r.side }}
</span>
</td>
<td class="ta-r mono">{{ r.balance | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="glr-empty">No customer relationships for this period and branch.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- ══════════════ BANKER HEAD REPORT ══════════════ -->
@if (activeReport === 'banker-head') {
<div class="glr-section">
<p class="glr-note">
Head-wise abstract for <strong>{{ rangeLabel }}</strong> — the trial balance rolled up to principal heads, with
opening, turnover and closing for each. Debit and credit turnover must agree.
</p>
<div class="glr-tbl-wrap">
<table class="glr-tbl">
<thead>
<tr>
<th>Principal Head</th>
<th class="ta-c">Accounts</th>
<th class="ta-r">Opening</th>
<th class="ta-r">Debit</th>
<th class="ta-r">Credit</th>
<th class="ta-r">Closing</th>
</tr>
</thead>
<tbody>
@for (r of bankerHeadRows; track r.head) {
<tr>
<td>
<strong>{{ r.head }}</strong>
</td>
<td class="ta-c mono">{{ r.accounts }}</td>
<td class="ta-r mono">{{ r.opening | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ r.debit ? (r.debit | number: '1.2-2') : '' }}</td>
<td class="ta-r mono">{{ r.credit ? (r.credit | number: '1.2-2') : '' }}</td>
<td class="ta-r mono">
<strong>{{ r.closing | number: '1.2-2' }}</strong>
</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="glr-empty">No activity for this period and branch.</td>
</tr>
}
<tr class="total-row">
<td colspan="2">TOTAL ({{ bankerHeadRows.length }} heads)</td>
<td></td>
<td class="ta-r mono">{{ headDebitTotal | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ headCreditTotal | number: '1.2-2' }}</td>
<td></td>
</tr>
</tbody>
</table>
</div>
</div>
}
<!-- ══════════════ SUNDRY REGISTER ══════════════ -->
@if (activeReport === 'sundry-register') {
<div class="glr-section">
<p class="glr-note">
Outstanding items in the sundry, suspense and bills-payable heads as at <strong>{{ range.to }}</strong
>, aged from their posting date. These heads are where unreconciled entries collect, so ageing is the point of
the register.
</p>
@if (sundryBuckets.length) {
<div class="glr-kpis">
@for (b of sundryBuckets; track b.bucket) {
<div
class="glr-kpi"
[class.glr-kpi--green]="b.bucket === 'Up to 30 days'"
[class.glr-kpi--amber]="b.bucket === '31 to 90 days'"
[class.glr-kpi--red]="b.bucket === 'Over 180 days'"
>
<div class="glr-kpi-n">&#8377;{{ b.amount | number: '1.2-2' }}</div>
<div class="glr-kpi-l">{{ b.bucket }}</div>
<div class="glr-kpi-sub">{{ b.count }} item(s)</div>
</div>
}
</div>
}
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
<table class="glr-tbl">
<thead>
<tr>
<th>Date</th>
<th>Voucher No</th>
<th>Branch</th>
<th>GL Code</th>
<th>Account</th>
<th>Narration</th>
<th class="ta-r">Amount (&#8377;)</th>
<th class="ta-c">Age</th>
</tr>
</thead>
<tbody>
@for (r of sundryRows; track r.voucherNo + r.code + r.date) {
<tr>
<td class="mono">{{ r.date }}</td>
<td class="mono">{{ r.voucherNo }}</td>
<td>{{ branchName(r.branchCode) }}</td>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}</td>
<td class="muted">{{ r.narration }}</td>
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
<td class="ta-c">
<span
class="type-pill"
[class.type-income]="r.bucket === 'Up to 30 days'"
[class.type-expense]="r.bucket === '31 to 90 days'"
[class.type-contingent]="r.bucket === 'Over 180 days'"
>
{{ r.ageDays }}d
</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="glr-empty">No sundry or suspense items outstanding for this selection.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="6">Total outstanding ({{ sundryRows.length }} items)</td>
<td class="ta-r mono">{{ sundryTotal | number: '1.2-2' }}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</div>
}
<!-- ══════════════ VOUCHER REGISTER ══════════════ -->
@if (activeReport === 'voucher-register') {
<div class="glr-section">
<div class="glr-balance" [class.glr-balance--bad]="unbalancedVouchers > 0">
<fa-icon [icon]="unbalancedVouchers ? 'exclamation-triangle' : 'check-circle'"></fa-icon>
<span>
{{ voucherRows.length | number }} voucher(s) raised in {{ rangeLabel }} for {{ branchLabel }} —
{{
unbalancedVouchers
? unbalancedVouchers + ' voucher(s) do NOT balance and must be investigated'
: 'every voucher is double-entry and balances'
}}
</span>
</div>
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
<table class="glr-tbl">
<thead>
<tr>
<th>Date</th>
<th>Voucher No</th>
<th>Branch</th>
<th>Narration</th>
<th>Debit account(s)</th>
<th>Credit account(s)</th>
<th class="ta-r">Amount (&#8377;)</th>
<th class="ta-c">Legs</th>
</tr>
</thead>
<tbody>
@for (r of voucherRows; track r.voucherNo) {
<tr [class.glr-unbalanced]="!r.balanced">
<td class="mono">{{ r.date }}</td>
<td class="mono">{{ r.voucherNo }}</td>
<td>{{ branchName(r.branchCode) }}</td>
<td class="muted">{{ r.narration }}</td>
<td class="mono">{{ r.debitAccounts }}</td>
<td class="mono">{{ r.creditAccounts }}</td>
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
<td class="ta-c mono">{{ r.legs }}</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="glr-empty">No vouchers raised in this period for this branch.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="6">Total ({{ voucherRows.length }} vouchers)</td>
<td class="ta-r mono">{{ voucherTotal | number: '1.2-2' }}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</div>
}
<!-- TOAST --> <!-- TOAST -->
<div class="glr-toast" [class.show]="toastVisible">{{ toastMsg }}</div> <div class="glr-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
</div> </div>
@@ -160,6 +160,21 @@
} }
} }
/* 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 */ /* Day / Month / Range segmented control */
.glr-seg { .glr-seg {
display: inline-flex; display: inline-flex;
@@ -434,6 +449,45 @@
font-style: italic; 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 */ /* Account-type pills on the GL Wise report */
.type-pill { .type-pill {
display: inline-block; display: inline-block;
+743 -33
View File
@@ -6,24 +6,42 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import { Component, OnInit, inject } from '@angular/core'; import { Component, DestroyRef, OnInit, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { BranchScopeService } from 'app/core/branch-scope.service';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import * as ExcelJS from 'exceljs'; import * as ExcelJS from 'exceljs';
import { GL_BRANCHES, GL_PERIOD, glBranchName } from './gl-reports-data'; import { GL_ACCOUNTS, GL_BRANCHES, GL_PERIOD, glBranchName } from './gl-reports-data';
import { import {
AccountBranchRow,
AverageBalanceRow,
BalanceSheetRow, BalanceSheetRow,
ComparativeRow, ComparativeRow,
DistributionRow,
GlDateRange, GlDateRange,
GlReportSet, GlReportSet,
MiscExpenseRow, MiscExpenseRow,
PnlRow, PnlRow,
BankerHeadRow,
ClientBalanceRow,
SundryItemRow,
VoucherRow,
accountWiseByBranch,
averageBalances,
balanceDistribution,
bankerHeadSummary,
clientWiseBalances,
sundryRegister,
voucherRegister,
buildReports, buildReports,
clampToLedger, clampToLedger,
glValueForCodes,
sum sum
} from './gl-reports.compute'; } from './gl-reports.compute';
import { GlMappingEntry, allMappings, glLabel, returnName } from 'app/rbi-circulars/gl-mapping';
export type GlReportCode = export type GlReportCode =
| 'trial-balance' | 'trial-balance'
@@ -32,7 +50,15 @@ export type GlReportCode =
| 'comparative-balance-sheet' | 'comparative-balance-sheet'
| 'contingent-liabilities' | 'contingent-liabilities'
| 'gl-wise-dr-cr' | 'gl-wise-dr-cr'
| 'misc-expense-ledger'; | 'misc-expense-ledger'
| 'gl-average-balance'
| 'gl-mapping'
| 'account-wise-balance'
| 'distribution-of-balance'
| 'client-wise-balance'
| 'banker-head'
| 'sundry-register'
| 'voucher-register';
type PeriodMode = 'day' | 'month' | 'range'; type PeriodMode = 'day' | 'month' | 'range';
@@ -49,6 +75,19 @@ type PeriodMode = 'day' | 'month' | 'range';
export class GlReportsComponent implements OnInit { export class GlReportsComponent implements OnInit {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private scope = inject(BranchScopeService);
private destroyRef = inject(DestroyRef);
/** Head Office may pick any branch; a branch login is locked to its own book. */
get canSelectBranch(): boolean {
return this.scope.canSelectBranch;
}
get scopeIsUnresolved(): boolean {
return this.scope.isUnresolved;
}
get scopeName(): string {
return this.scope.scopeLabel(this.branchCode);
}
readonly reports: { code: GlReportCode; label: string; icon: string }[] = [ readonly reports: { code: GlReportCode; label: string; icon: string }[] = [
{ code: 'trial-balance', label: 'Trial Balance', icon: 'balance-scale' }, { code: 'trial-balance', label: 'Trial Balance', icon: 'balance-scale' },
@@ -57,7 +96,15 @@ export class GlReportsComponent implements OnInit {
{ code: 'comparative-balance-sheet', label: 'Comparative Balance Sheet', icon: 'balance-scale' }, { code: 'comparative-balance-sheet', label: 'Comparative Balance Sheet', icon: 'balance-scale' },
{ code: 'contingent-liabilities', label: 'Contingent Liabilities', icon: 'exclamation-circle' }, { code: 'contingent-liabilities', label: 'Contingent Liabilities', icon: 'exclamation-circle' },
{ code: 'gl-wise-dr-cr', label: 'GL Wise DR / CR', icon: 'project-diagram' }, { code: 'gl-wise-dr-cr', label: 'GL Wise DR / CR', icon: 'project-diagram' },
{ code: 'misc-expense-ledger', label: 'Misc Expense Ledger', icon: 'receipt' } { code: 'misc-expense-ledger', label: 'Misc Expense Ledger', icon: 'receipt' },
{ code: 'gl-average-balance', label: 'GL Average Balance', icon: 'calculator' },
{ code: 'gl-mapping', label: 'GL Mapping for Report', icon: 'project-diagram' },
{ code: 'account-wise-balance', label: 'Account wise Balance List', icon: 'list' },
{ code: 'distribution-of-balance', label: 'Distribution of Balance', icon: 'chart-pie' },
{ code: 'client-wise-balance', label: 'Client wise Balance', icon: 'users' },
{ code: 'banker-head', label: 'Banker Head Report', icon: 'university' },
{ code: 'sundry-register', label: 'Sundry Register', icon: 'clipboard-list' },
{ code: 'voucher-register', label: 'Voucher Register', icon: 'receipt' }
]; ];
activeReport: GlReportCode = 'trial-balance'; activeReport: GlReportCode = 'trial-balance';
@@ -80,16 +127,29 @@ export class GlReportsComponent implements OnInit {
private toastTimer: any; private toastTimer: any;
ngOnInit(): void { ngOnInit(): void {
const param = this.route.snapshot.paramMap.get('report') as GlReportCode | null; // Subscribe rather than read the snapshot: navigating between GL menu items only
if (param && this.reports.some((r) => r.code === param)) { // changes the route parameter, so Angular reuses this component and ngOnInit would
this.activeReport = param; // never fire again — leaving the previously selected report on screen.
} this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
const param = params.get('report') as GlReportCode | null;
if (param && this.reports.some((r) => r.code === param)) {
this.activeReport = param;
}
});
// A branch login opens on its own book and cannot move off it.
this.branchCode = this.scope.defaultBranchCode;
} }
showReport(code: GlReportCode): void { showReport(code: GlReportCode): void {
this.activeReport = code; this.activeReport = code;
// Keep the URL deep-linkable so the GL menu items land on the right report. // Keep the URL deep-linkable so the GL menu items land on the right report.
this.router.navigate(['/gl/reports', code], { replaceUrl: true }); this.router.navigate(
[
'/gl/reports',
code
],
{ replaceUrl: true }
);
} }
get reportLabel(): string { get reportLabel(): string {
@@ -120,21 +180,33 @@ export class GlReportsComponent implements OnInit {
} }
get branchLabel(): string { get branchLabel(): string {
if (!this.canSelectBranch) {
return this.scopeName;
}
return this.branchCode ? glBranchName(this.branchCode) : 'All Branches (Consolidated)'; return this.branchCode ? glBranchName(this.branchCode) : 'All Branches (Consolidated)';
} }
/**
* The branch actually used for every query. Applied at the data boundary rather than
* only in the UI so a branch login cannot widen its scope by tampering with state.
*/
private get effectiveBranch(): string {
return this.scope.enforce(this.branchCode);
}
resetFilters(): void { resetFilters(): void {
this.periodMode = 'month'; this.periodMode = 'month';
this.filterMonth = '2026-07'; this.filterMonth = '2026-07';
this.filterDay = '2026-07-31'; this.filterDay = '2026-07-31';
this.filterFrom = GL_PERIOD.fyStart; this.filterFrom = GL_PERIOD.fyStart;
this.filterTo = GL_PERIOD.fyEnd; this.filterTo = GL_PERIOD.fyEnd;
this.branchCode = ''; // Reset returns a branch login to its own branch, never to "all".
this.branchCode = this.scope.defaultBranchCode;
} }
/** All seven reports for the current filter — memoised inside buildReports. */ /** All seven reports for the current filter — memoised inside buildReports. */
get data(): GlReportSet { get data(): GlReportSet {
return buildReports(this.range, this.branchCode); return buildReports(this.range, this.effectiveBranch);
} }
// ── Trial Balance ────────────────────────────────────────────────────────── // ── Trial Balance ──────────────────────────────────────────────────────────
@@ -234,6 +306,139 @@ export class GlReportsComponent implements OnInit {
return glBranchName(code); return glBranchName(code);
} }
// ── GL Average Balance ─────────────────────────────────────────────────────
get averageRows(): AverageBalanceRow[] {
return averageBalances(this.range, this.effectiveBranch);
}
get avgTotalAverage(): number {
return sum(this.averageRows, (r) => r.average);
}
// ── GL Mapping for Report ──────────────────────────────────────────────────
/** Every RBI return line currently pinned to GL accounts, with its ledger value. */
get mappingRows(): {
returnCode: string;
returnLabel: string;
lineKey: string;
codes: string[];
codeLabels: string;
value: number;
}[] {
return allMappings()
.map((m: GlMappingEntry) => ({
returnCode: m.returnCode,
returnLabel: returnName(m.returnCode),
lineKey: m.lineKey,
codes: m.codes,
codeLabels: m.codes.map((c) => glLabel(c)).join(', '),
// Returns are in thousands; the ledger is in rupees.
value: glValueForCodes(m.codes, this.range, this.effectiveBranch) / 1000
}))
.sort((a, b) => a.returnCode.localeCompare(b.returnCode) || a.lineKey.localeCompare(b.lineKey));
}
/** GL accounts not pinned to any return line — the gap a mapping review looks for. */
get unmappedAccounts(): { code: string; name: string; group: string }[] {
const used = new Set(allMappings().flatMap((m) => m.codes));
return this.chartOfAccounts
.filter((a) => !used.has(a.code))
.map((a) => ({ code: a.code, name: a.name, group: a.group }));
}
readonly chartOfAccounts = GL_ACCOUNTS;
// ── Account wise Balance List ──────────────────────────────────────────────
/** Branch columns shown — every branch for Head Office, just their own otherwise. */
get branchColumns(): string[] {
return this.canSelectBranch ? GL_BRANCHES.map((b) => b.code) : [this.effectiveBranch];
}
get accountWiseRows(): AccountBranchRow[] {
return accountWiseByBranch(this.range, this.branchColumns);
}
branchColumnTotal(code: string): number {
return sum(this.accountWiseRows, (r) => r.byBranch[code] ?? 0);
}
get accountWiseGrandTotal(): number {
return sum(this.accountWiseRows, (r) => r.total);
}
// ── Client wise Balance ────────────────────────────────────────────────────
get clientRows(): ClientBalanceRow[] {
return clientWiseBalances(this.range, this.effectiveBranch);
}
get clientDeposits(): number {
return sum(
this.clientRows.filter((r) => r.side === 'DEPOSIT'),
(r) => r.balance
);
}
get clientAdvances(): number {
return sum(
this.clientRows.filter((r) => r.side === 'ADVANCE'),
(r) => r.balance
);
}
// ── Banker Head Report ─────────────────────────────────────────────────────
get bankerHeadRows(): BankerHeadRow[] {
return bankerHeadSummary(this.range, this.effectiveBranch);
}
get headDebitTotal(): number {
return sum(this.bankerHeadRows, (r) => r.debit);
}
get headCreditTotal(): number {
return sum(this.bankerHeadRows, (r) => r.credit);
}
// ── Sundry Register ────────────────────────────────────────────────────────
get sundryRows(): SundryItemRow[] {
return sundryRegister(this.range, this.effectiveBranch);
}
get sundryTotal(): number {
return sum(this.sundryRows, (r) => r.amount);
}
/** Ageing summary shown above the register. */
get sundryBuckets(): { bucket: string; count: number; amount: number }[] {
const order = [
'Up to 30 days',
'31 to 90 days',
'91 to 180 days',
'Over 180 days'
];
return order
.map((bucket) => {
const rows = this.sundryRows.filter((r) => r.bucket === bucket);
return { bucket, count: rows.length, amount: sum(rows, (r) => r.amount) };
})
.filter((b) => b.count > 0);
}
// ── Voucher Register ───────────────────────────────────────────────────────
get voucherRows(): VoucherRow[] {
return voucherRegister(this.range, this.effectiveBranch);
}
get voucherTotal(): number {
return sum(this.voucherRows, (r) => r.amount);
}
/** Vouchers whose debits and credits do not agree — should always be zero. */
get unbalancedVouchers(): number {
return this.voucherRows.filter((r) => !r.balanced).length;
}
// ── Distribution of Balance ────────────────────────────────────────────────
get assetDistribution(): DistributionRow[] {
return balanceDistribution(this.range, this.effectiveBranch, 'ASSET');
}
get liabilityDistribution(): DistributionRow[] {
return balanceDistribution(this.range, this.effectiveBranch, 'LIABILITY');
}
get assetDistTotal(): number {
return sum(this.assetDistribution, (r) => r.amount);
}
get liabilityDistTotal(): number {
return sum(this.liabilityDistribution, (r) => r.amount);
}
// ── Excel export ─────────────────────────────────────────────────────────── // ── Excel export ───────────────────────────────────────────────────────────
/** Exports whichever report is on screen, carrying the active filter into the header. */ /** Exports whichever report is on screen, carrying the active filter into the header. */
@@ -314,7 +519,17 @@ export class GlReportsComponent implements OnInit {
'Closing Dr', 'Closing Dr',
'Closing Cr' 'Closing Cr'
], ],
widths: [12, 42, 26, 16, 16, 16, 16, 16, 16], widths: [
12,
42,
26,
16,
16,
16,
16,
16,
16
],
rows: [ rows: [
...this.data.trialBalance.map((r) => [ ...this.data.trialBalance.map((r) => [
r.code, r.code,
@@ -349,13 +564,49 @@ export class GlReportsComponent implements OnInit {
'Group', 'Group',
'Amount' 'Amount'
], ],
widths: [14, 12, 42, 26, 18], widths: [
14,
12,
42,
26,
18
],
rows: [ rows: [
...this.pnlExpense.map((r) => ['Expenditure', r.code, r.name, r.group, r.amount]), ...this.pnlExpense.map((r) => [
['Total Expenditure', '', '', '', this.totalExpense], 'Expenditure',
...this.pnlIncome.map((r) => ['Income', r.code, r.name, r.group, r.amount]), r.code,
['Total Income', '', '', '', this.totalIncome], r.name,
['Net Profit', '', '', '', this.netProfit] r.group,
r.amount
]),
[
'Total Expenditure',
'',
'',
'',
this.totalExpense
],
...this.pnlIncome.map((r) => [
'Income',
r.code,
r.name,
r.group,
r.amount
]),
[
'Total Income',
'',
'',
'',
this.totalIncome
],
[
'Net Profit',
'',
'',
'',
this.netProfit
]
] ]
}; };
case 'balance-sheet': case 'balance-sheet':
@@ -367,13 +618,49 @@ export class GlReportsComponent implements OnInit {
'Group', 'Group',
'Amount' 'Amount'
], ],
widths: [14, 12, 42, 26, 18], widths: [
14,
12,
42,
26,
18
],
rows: [ rows: [
...this.bsLiabilities.map((r) => ['Liability', r.code, r.name, r.group, r.amount]), ...this.bsLiabilities.map((r) => [
['Liability', '', 'Surplus for the period', 'Profit & Loss', this.bsSurplus], 'Liability',
['Total Liabilities', '', '', '', this.bsTotalLiabilities], r.code,
...this.bsAssets.map((r) => ['Asset', r.code, r.name, r.group, r.amount]), r.name,
['Total Assets', '', '', '', this.bsTotalAssets] r.group,
r.amount
]),
[
'Liability',
'',
'Surplus for the period',
'Profit & Loss',
this.bsSurplus
],
[
'Total Liabilities',
'',
'',
'',
this.bsTotalLiabilities
],
...this.bsAssets.map((r) => [
'Asset',
r.code,
r.name,
r.group,
r.amount
]),
[
'Total Assets',
'',
'',
'',
this.bsTotalAssets
]
] ]
}; };
case 'comparative-balance-sheet': case 'comparative-balance-sheet':
@@ -387,7 +674,15 @@ export class GlReportsComponent implements OnInit {
'Variance', 'Variance',
'Variance %' 'Variance %'
], ],
widths: [12, 12, 42, 18, 18, 18, 14], widths: [
12,
12,
42,
18,
18,
18,
14
],
rows: this.data.comparative.map((r) => [ rows: this.data.comparative.map((r) => [
r.side, r.side,
r.code, r.code,
@@ -406,10 +701,25 @@ export class GlReportsComponent implements OnInit {
'Group', 'Group',
'Amount' 'Amount'
], ],
widths: [12, 48, 26, 18], widths: [
12,
48,
26,
18
],
rows: [ rows: [
...this.data.contingent.map((r) => [r.code, r.name, r.group, r.amount]), ...this.data.contingent.map((r) => [
['TOTAL', '', '', this.contingentTotal] r.code,
r.name,
r.group,
r.amount
]),
[
'TOTAL',
'',
'',
this.contingentTotal
]
] ]
}; };
case 'gl-wise-dr-cr': case 'gl-wise-dr-cr':
@@ -424,10 +734,394 @@ export class GlReportsComponent implements OnInit {
'Net (Dr-Cr)', 'Net (Dr-Cr)',
'Vouchers' 'Vouchers'
], ],
widths: [12, 42, 14, 26, 18, 18, 18, 12], widths: [
12,
42,
14,
26,
18,
18,
18,
12
],
rows: [ rows: [
...this.data.glWise.map((r) => [r.code, r.name, r.type, r.group, r.debit, r.credit, r.net, r.vouchers]), ...this.data.glWise.map((r) => [
['TOTAL', '', '', '', this.glWiseDebit, this.glWiseCredit, this.glWiseDebit - this.glWiseCredit, null] r.code,
r.name,
r.type,
r.group,
r.debit,
r.credit,
r.net,
r.vouchers
]),
[
'TOTAL',
'',
'',
'',
this.glWiseDebit,
this.glWiseCredit,
this.glWiseDebit - this.glWiseCredit,
null
]
]
};
case 'gl-average-balance':
return {
headers: [
'GL Code',
'Account',
'Group',
'Opening',
'Closing',
'Average Daily Balance',
'Peak',
'Lowest',
'Active Days'
],
widths: [
12,
42,
26,
18,
18,
22,
18,
18,
12
],
rows: [
...this.averageRows.map((r) => [
r.code,
r.name,
r.group,
r.opening,
r.closing,
r.average,
r.peak,
r.lowest,
r.activeDays
]),
[
'TOTAL',
'',
'',
null,
null,
this.avgTotalAverage,
null,
null,
null
]
]
};
case 'gl-mapping':
return {
headers: [
'Return',
'Return Line',
'Mapped GL Accounts',
'Ledger Value (Rs Thousands)'
],
widths: [
42,
46,
60,
24
],
rows: [
...this.mappingRows.map((r) => [
r.returnLabel,
r.lineKey,
r.codeLabels,
r.value
]),
...(this.mappingRows.length ? [] : [[
'— no return lines mapped —',
'',
'',
null
]]),
[
'',
'',
'',
null
],
[
'UNMAPPED GL ACCOUNTS',
'',
'',
null
],
...this.unmappedAccounts.map((a) => [
'',
a.code + ' — ' + a.name,
a.group,
null
])
]
};
case 'account-wise-balance':
return {
headers: [
'GL Code',
'Account',
'Group',
...this.branchColumns.map((c) => this.branchName(c)),
'Total'
],
widths: [
12,
40,
24,
...this.branchColumns.map(() => 18),
20
],
rows: [
...this.accountWiseRows.map((r) => [
r.code,
r.name,
r.group,
...this.branchColumns.map((c) => r.byBranch[c] ?? 0),
r.total
]),
[
'TOTAL',
'',
'',
...this.branchColumns.map((c) => this.branchColumnTotal(c)),
this.accountWiseGrandTotal
]
]
};
case 'distribution-of-balance':
return {
headers: [
'Side',
'Group',
'Accounts',
'Amount',
'Share %'
],
widths: [
16,
40,
14,
22,
14
],
rows: [
...this.liabilityDistribution.map((r) => [
'Liability',
r.group,
r.accounts,
r.amount,
Number(r.share.toFixed(2))
]),
[
'Total Liabilities',
'',
null,
this.liabilityDistTotal,
100
],
...this.assetDistribution.map((r) => [
'Asset',
r.group,
r.accounts,
r.amount,
Number(r.share.toFixed(2))
]),
[
'Total Assets',
'',
null,
this.assetDistTotal,
100
]
]
};
case 'client-wise-balance':
return {
headers: [
'CIF',
'Customer',
'Branch',
'Account No',
'Product',
'Side',
'Balance'
],
widths: [
14,
34,
18,
24,
34,
12,
20
],
rows: [
...this.clientRows.map((r) => [
r.cif,
r.name,
this.branchName(r.branchCode),
r.accountNo,
r.product,
r.side,
r.balance
]),
[
'TOTAL DEPOSITS',
'',
'',
'',
'',
'',
this.clientDeposits
],
[
'TOTAL ADVANCES',
'',
'',
'',
'',
'',
this.clientAdvances
]
]
};
case 'banker-head':
return {
headers: [
'Principal Head',
'Accounts',
'Opening',
'Debit',
'Credit',
'Closing'
],
widths: [
34,
12,
20,
20,
20,
20
],
rows: [
...this.bankerHeadRows.map((r) => [
r.head,
r.accounts,
r.opening,
r.debit,
r.credit,
r.closing
]),
[
'TOTAL',
null,
null,
this.headDebitTotal,
this.headCreditTotal,
null
]
]
};
case 'sundry-register':
return {
headers: [
'Date',
'Voucher No',
'Branch',
'GL Code',
'Account',
'Narration',
'Amount',
'Age (days)',
'Ageing Bucket'
],
widths: [
14,
20,
18,
12,
32,
42,
18,
12,
18
],
rows: [
...this.sundryRows.map((r) => [
r.date,
r.voucherNo,
this.branchName(r.branchCode),
r.code,
r.name,
r.narration,
r.amount,
r.ageDays,
r.bucket
]),
[
'TOTAL',
'',
'',
'',
'',
'',
this.sundryTotal,
null,
''
]
]
};
case 'voucher-register':
return {
headers: [
'Date',
'Voucher No',
'Branch',
'Narration',
'Debit Account(s)',
'Credit Account(s)',
'Amount',
'Legs'
],
widths: [
14,
20,
18,
42,
38,
38,
20,
10
],
rows: [
...this.voucherRows.map((r) => [
r.date,
r.voucherNo,
this.branchName(r.branchCode),
r.narration,
r.debitAccounts,
r.creditAccounts,
r.amount,
r.legs
]),
[
'TOTAL',
'',
'',
'',
'',
'',
this.voucherTotal,
null
]
] ]
}; };
case 'misc-expense-ledger': case 'misc-expense-ledger':
@@ -442,7 +1136,15 @@ export class GlReportsComponent implements OnInit {
'Narration', 'Narration',
'Amount' 'Amount'
], ],
widths: [14, 20, 18, 12, 36, 46, 18], widths: [
14,
20,
18,
12,
36,
46,
18
],
rows: [ rows: [
...this.miscRows.map((r) => [ ...this.miscRows.map((r) => [
r.date, r.date,
@@ -453,7 +1155,15 @@ export class GlReportsComponent implements OnInit {
r.narration, r.narration,
r.amount r.amount
]), ]),
['TOTAL', '', '', '', '', '', this.miscTotal] [
'TOTAL',
'',
'',
'',
'',
'',
this.miscTotal
]
] ]
}; };
} }
+477 -13
View File
@@ -15,14 +15,7 @@
* postings and Angular re-evaluates template getters on every change detection. * postings and Angular re-evaluates template getters on every change detection.
*/ */
import { import { GL_ACCOUNTS, GL_PERIOD, GlAccount, GlPosting, glAccount, glCustomers, glPostings } from './gl-reports-data';
GL_ACCOUNTS,
GL_PERIOD,
GlAccount,
GlPosting,
glAccount,
glPostings
} from './gl-reports-data';
export interface GlDateRange { export interface GlDateRange {
from: string; from: string;
@@ -96,8 +89,22 @@ function matchesBranch(p: GlPosting, branchCode: string): boolean {
return !branchCode || p.branchCode === branchCode; 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`. */ /** Signed balance on the account's natural side, for postings up to and including `to`. */
function cumulativeBalances(to: string, branchCode: string): Map<string, number> { 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>(); const out = new Map<string, number>();
for (const p of glPostings()) { for (const p of glPostings()) {
if (p.date > to || !matchesBranch(p, branchCode)) { if (p.date > to || !matchesBranch(p, branchCode)) {
@@ -110,11 +117,20 @@ function cumulativeBalances(to: string, branchCode: string): Map<string, number>
const delta = isDebitSide(account) ? p.debit - p.credit : p.credit - p.debit; const delta = isDebitSide(account) ? p.debit - p.credit : p.credit - p.debit;
out.set(p.accountCode, (out.get(p.accountCode) ?? 0) + delta); out.set(p.accountCode, (out.get(p.accountCode) ?? 0) + delta);
} }
balanceCache.set(key, out);
return out; return out;
} }
/** Debit and credit turnover strictly inside the range. */ /** Debit and credit turnover strictly inside the range. */
function periodTurnover(range: GlDateRange, branchCode: string): Map<string, { debit: number; credit: number; vouchers: Set<string> }> { 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> }>(); const out = new Map<string, { debit: number; credit: number; vouchers: Set<string> }>();
for (const p of glPostings()) { for (const p of glPostings()) {
if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) { if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) {
@@ -129,6 +145,7 @@ function periodTurnover(range: GlDateRange, branchCode: string): Map<string, { d
entry.credit += p.credit; entry.credit += p.credit;
entry.vouchers.add(p.voucherNo); entry.vouchers.add(p.voucherNo);
} }
turnoverCache.set(key, out);
return out; return out;
} }
@@ -190,8 +207,7 @@ export function profitAndLoss(range: GlDateRange, branchCode: string): { income:
if (!move) { if (!move) {
continue; continue;
} }
const amount = const amount = account.type === 'INCOME' ? move.credit - move.debit : move.debit - move.credit;
account.type === 'INCOME' ? move.credit - move.debit : move.debit - move.credit;
if (!amount) { if (!amount) {
continue; continue;
} }
@@ -205,7 +221,10 @@ export function profitAndLoss(range: GlDateRange, branchCode: string): { income:
* Balance sheet as at the period end. The current-period surplus is folded in as * 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. * a liability line so the two sides tally the way the printed statement does.
*/ */
export function balanceSheet(range: GlDateRange, branchCode: string): { export function balanceSheet(
range: GlDateRange,
branchCode: string
): {
assets: BalanceSheetRow[]; assets: BalanceSheetRow[];
liabilities: BalanceSheetRow[]; liabilities: BalanceSheetRow[];
surplus: number; surplus: number;
@@ -315,7 +334,417 @@ export function miscExpenseLedger(range: GlDateRange, branchCode: string): MiscE
amount: p.debit amount: p.debit
}); });
} }
return rows.sort((a, b) => (a.date === b.date ? a.voucherNo.localeCompare(b.voucherNo) : a.date.localeCompare(b.date))); 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 ────────────────────────────────────────────────────────────── // ── Memoisation ──────────────────────────────────────────────────────────────
@@ -360,6 +789,41 @@ export function clampToLedger(range: GlDateRange): GlDateRange {
}; };
} }
/**
* 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 { export function sum<T>(rows: T[], pick: (row: T) => number): number {
return rows.reduce((acc, r) => acc + pick(r), 0); return rows.reduce((acc, r) => acc + pick(r), 0);
} }
@@ -25,6 +25,11 @@
} }
</select> </select>
</label> </label>
@if (activeTab === 'holdings') {
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
}
</div> </div>
</header> </header>
@@ -187,8 +192,8 @@
><span>Deal No</span><input type="text" [(ngModel)]="purchaseForm.dealNo" readonly ><span>Deal No</span><input type="text" [(ngModel)]="purchaseForm.dealNo" readonly
/></label> /></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>* Deal Date</span><input type="text" [(ngModel)]="purchaseForm.dealDate" placeholder="DD-MM-YYYY" ><span>* Deal Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.dealDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>* Deal Time (24 hr, HH:mm:ss)</span ><span>* Deal Time (24 hr, HH:mm:ss)</span
><input type="text" [(ngModel)]="purchaseForm.dealTime" placeholder="HH:mm:ss" ><input type="text" [(ngModel)]="purchaseForm.dealTime" placeholder="HH:mm:ss"
@@ -216,8 +221,8 @@
</label> </label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Settlement Dt</span ><span>Settlement Dt</span
><input type="text" [(ngModel)]="purchaseForm.settlementDt" placeholder="DD-MM-YYYY" ><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.settlementDt"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>* Party Code</span> <span>* Party Code</span>
<select [(ngModel)]="purchaseForm.partyCode"> <select [(ngModel)]="purchaseForm.partyCode">
@@ -328,8 +333,8 @@
</select> </select>
</label> </label>
<label class="inv-form-field" <label class="inv-form-field"
><span>* Issue Date</span><input type="text" [(ngModel)]="purchaseForm.issueDate" placeholder="DD-MM-YYYY" ><span>* Issue Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.issueDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Period(Year)</span><input type="number" [(ngModel)]="purchaseForm.periodYear" min="0" ><span>Period(Year)</span><input type="number" [(ngModel)]="purchaseForm.periodYear" min="0"
/></label> /></label>
@@ -349,8 +354,8 @@
><span>Call Option</span><input type="text" [(ngModel)]="purchaseForm.callOption" ><span>Call Option</span><input type="text" [(ngModel)]="purchaseForm.callOption"
/></label> /></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Meet Dt</span><input type="text" [(ngModel)]="purchaseForm.meetDt" placeholder="DD-MM-YYYY" ><span>Meet Dt</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.meetDt"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Reso No</span><input type="text" [(ngModel)]="purchaseForm.resoNo" ><span>Reso No</span><input type="text" [(ngModel)]="purchaseForm.resoNo"
/></label> /></label>
@@ -389,11 +394,11 @@
</select> </select>
</label> </label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Int Date</span><input type="text" [(ngModel)]="purchaseForm.intDate" placeholder="DD-MM-YYYY" ><span>Int Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.intDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>* Last Int.dt</span><input type="text" [(ngModel)]="purchaseForm.lastIntDt" placeholder="DD-MM-YYYY" ><span>* Last Int.dt</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.lastIntDt"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Accrued Interest (computed)</span ><span>Accrued Interest (computed)</span
><input type="text" [value]="purchaseAccruedInterest | number: '1.2-2'" readonly ><input type="text" [value]="purchaseAccruedInterest | number: '1.2-2'" readonly
@@ -549,8 +554,8 @@
/></label> /></label>
} }
<label class="inv-form-field" <label class="inv-form-field"
><span>Redemption Date *</span><input type="text" [(ngModel)]="redeemDate" placeholder="DD-MM-YYYY" ><span>Redemption Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="redeemDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Redemption Proceeds (computed)</span ><span>Redemption Proceeds (computed)</span
><input type="text" [value]="'₹' + (redeemAmount | number: '1.2-2')" readonly ><input type="text" [value]="'₹' + (redeemAmount | number: '1.2-2')" readonly
@@ -570,11 +575,14 @@
<div class="pos-section-label">Investment - Govt./Bond Interest calculation</div> <div class="pos-section-label">Investment - Govt./Bond Interest calculation</div>
<div class="inv-form-grid"> <div class="inv-form-grid">
<label class="inv-form-field" <label class="inv-form-field"
><span>Date</span><input type="text" [(ngModel)]="intCalcDate" placeholder="DD-MM-YYYY" ><span>From Date</span><mifosx-date-ddmmyyyy [(ngModel)]="intCalcFromDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field"
><span>To Date</span><mifosx-date-ddmmyyyy [(ngModel)]="intCalcToDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>Type</span> <span>Type</span>
<select [(ngModel)]="intCalcType"> <select [(ngModel)]="intCalcType" (ngModelChange)="onIntCalcTypeChange()">
<option value="">-- All Types --</option> <option value="">-- All Types --</option>
@for (o of bondTypeOptions; track o.value) { @for (o of bondTypeOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option> <option [value]="o.value">{{ o.label }}</option>
@@ -594,11 +602,25 @@
<label class="inv-form-field" <label class="inv-form-field"
><span>ALL</span><input type="checkbox" [(ngModel)]="intCalcAll" style="height: 18px; width: 18px" ><span>ALL</span><input type="checkbox" [(ngModel)]="intCalcAll" style="height: 18px; width: 18px"
/></label> /></label>
<label class="inv-form-field">
<span>Post To (Interest Income GL)</span>
<select [(ngModel)]="intCalcGlCode">
@for (g of interestIncomeGlOptions; track g.glCode) {
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
}
</select>
</label>
</div> </div>
<div class="inv-form-actions"> <div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="processInterest()">Process</button> <button type="button" class="pg-btn pg-btn--primary" (click)="processInterest()">Process</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="setTab('holdings')">Exit</button> <button type="button" class="pg-btn pg-btn--ghost" (click)="setTab('holdings')">Exit</button>
</div> </div>
@if (intCalcExcludedNpi.length > 0) {
<p class="pg-sub" style="padding: 0 16px 12px">
{{ intCalcExcludedNpi.length }} holding(s) excluded — classified Non-Performing, interest is not posted on
accrual basis (collectible on cash basis only, via Interest Receive in the Investment Interest module).
</p>
}
<div class="pos-section-label">Holdings matching the filter ({{ intCalcTargets.length }})</div> <div class="pos-section-label">Holdings matching the filter ({{ intCalcTargets.length }})</div>
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
@@ -689,8 +711,8 @@
><span>TBill GL.A/C.</span><input type="text" [(ngModel)]="sellForm.tbillGlAc" ><span>TBill GL.A/C.</span><input type="text" [(ngModel)]="sellForm.tbillGlAc"
/></label> /></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>* Deal Date</span><input type="text" [(ngModel)]="sellForm.dealDate" placeholder="DD-MM-YYYY" ><span>* Deal Date</span><mifosx-date-ddmmyyyy [(ngModel)]="sellForm.dealDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>* Deal Time</span><input type="text" [(ngModel)]="sellForm.dealTime" placeholder="HH:mm:ss" ><span>* Deal Time</span><input type="text" [(ngModel)]="sellForm.dealTime" placeholder="HH:mm:ss"
/></label> /></label>
@@ -703,8 +725,8 @@
</select> </select>
</label> </label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Settlement Dt</span><input type="text" [(ngModel)]="sellForm.settlementDt" placeholder="DD-MM-YYYY" ><span>Settlement Dt</span><mifosx-date-ddmmyyyy [(ngModel)]="sellForm.settlementDt"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>* Face Value</span><input type="number" [(ngModel)]="sellForm.faceValue" min="0" ><span>* Face Value</span><input type="number" [(ngModel)]="sellForm.faceValue" min="0"
/></label> /></label>
@@ -937,8 +959,8 @@
</select> </select>
</label> </label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Transfer Date *</span><input type="text" [(ngModel)]="goiTransferDate" placeholder="DD-MM-YYYY" ><span>Transfer Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="goiTransferDate"></mifosx-date-ddmmyyyy
/></label> ></label>
</div> </div>
<div class="inv-form-actions"> <div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="saveGoiTransfer()">Save</button> <button type="button" class="pg-btn pg-btn--primary" (click)="saveGoiTransfer()">Save</button>
@@ -953,8 +975,8 @@
<div class="pos-section-label">Investment - Investment Depreciation (Mark-to-Market Provisioning)</div> <div class="pos-section-label">Investment - Investment Depreciation (Mark-to-Market Provisioning)</div>
<div class="inv-form-grid"> <div class="inv-form-grid">
<label class="inv-form-field" <label class="inv-form-field"
><span>Valuation Date</span><input type="text" [(ngModel)]="depreciationDate" placeholder="DD-MM-YYYY" ><span>Valuation Date</span><mifosx-date-ddmmyyyy [(ngModel)]="depreciationDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>Category</span> <span>Category</span>
<select [(ngModel)]="depreciationCategory"> <select [(ngModel)]="depreciationCategory">
@@ -1038,8 +1060,8 @@
><span>Units to Transfer *</span><input type="number" [(ngModel)]="mfTransferUnits" min="0" step="0.001" ><span>Units to Transfer *</span><input type="number" [(ngModel)]="mfTransferUnits" min="0" step="0.001"
/></label> /></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Transfer Date *</span><input type="text" [(ngModel)]="mfTransferDate" placeholder="DD-MM-YYYY" ><span>Transfer Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="mfTransferDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Transfer Value (computed)</span ><span>Transfer Value (computed)</span
><input type="text" [value]="'₹' + (mfTransferValue | number: '1.2-2')" readonly ><input type="text" [value]="'₹' + (mfTransferValue | number: '1.2-2')" readonly
@@ -1064,6 +1086,7 @@
<th>Cert No</th> <th>Cert No</th>
<th>Type</th> <th>Type</th>
<th>Particulars</th> <th>Particulars</th>
<th>GL Posted</th>
<th class="num">Amount</th> <th class="num">Amount</th>
</tr> </tr>
</thead> </thead>
@@ -1077,11 +1100,12 @@
<td>{{ t.certNo }}</td> <td>{{ t.certNo }}</td>
<td>{{ t.txnType }}</td> <td>{{ t.txnType }}</td>
<td>{{ t.particulars }}</td> <td>{{ t.particulars }}</td>
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
<td class="num">₹{{ t.amount | number: '1.2-2' }}</td> <td class="num">₹{{ t.amount | number: '1.2-2' }}</td>
</tr> </tr>
} @empty { } @empty {
<tr> <tr>
<td colspan="6" class="pg-empty">No transactions yet.</td> <td colspan="7" class="pg-empty">No transactions yet.</td>
</tr> </tr>
} }
</tbody> </tbody>
@@ -9,6 +9,7 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import { import {
GOVT_SECURITY_HOLDINGS, GOVT_SECURITY_HOLDINGS,
NON_SLR_HOLDINGS, NON_SLR_HOLDINGS,
@@ -30,12 +31,16 @@ import {
BOND_RATING_OPTIONS, BOND_RATING_OPTIONS,
INT_TYPE_OPTIONS, INT_TYPE_OPTIONS,
AUTHORITY_OPTIONS, AUTHORITY_OPTIONS,
INTEREST_INCOME_GL_MASTER,
INTEREST_INCOME_GL_BY_TYPE,
InvestmentHolding, InvestmentHolding,
InvestmentTxn, InvestmentTxn,
InstrumentType, InstrumentType,
InterestFreq, InterestFreq,
INSTRUMENT_LABELS, INSTRUMENT_LABELS,
accruedInterest, accruedInterest,
accruedInterestBetween,
canAccrueInterest,
daysToMaturity, daysToMaturity,
maturityAmount, maturityAmount,
addPeriod, addPeriod,
@@ -43,6 +48,7 @@ import {
yearsBetween, yearsBetween,
todayDDMMYYYY todayDDMMYYYY
} from '../investment-data'; } from '../investment-data';
import { exportWorkbook } from '../investment-export';
type BondType = 'GOVT_SECURITY' | 'NON_SLR' | 'BOND' | 'MUTUAL_FUND'; type BondType = 'GOVT_SECURITY' | 'NON_SLR' | 'BOND' | 'MUTUAL_FUND';
type BondTab = type BondTab =
@@ -172,7 +178,8 @@ interface BondSellForm {
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
FormsModule FormsModule,
DateDdmmyyyyPickerComponent
] ]
}) })
export class BondInvestmentComponent { export class BondInvestmentComponent {
@@ -210,6 +217,7 @@ export class BondInvestmentComponent {
readonly intTypeOptions = INT_TYPE_OPTIONS; readonly intTypeOptions = INT_TYPE_OPTIONS;
readonly authorityOptions = AUTHORITY_OPTIONS; readonly authorityOptions = AUTHORITY_OPTIONS;
generating = false;
toastMsg = ''; toastMsg = '';
toastVisible = false; toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined; private toastTimer: ReturnType<typeof setTimeout> | undefined;
@@ -222,12 +230,17 @@ export class BondInvestmentComponent {
redeemDate = this.asOnDate; redeemDate = this.asOnDate;
redeemRemarks = ''; redeemRemarks = '';
// Interest Calculation (legacy: Date / Type / Script / Deal No / ALL + Process) // Interest Calculation (legacy: From/To Date / Type / Script / Deal No / ALL + Process) —
intCalcDate = this.asOnDate; // posts interest accrued strictly within [intCalcFromDate, intCalcToDate], same period-based
// basis as the Investment Interest module's Govt./Bond Interest Calculation.
intCalcFromDate = addPeriod(this.asOnDate, 0, -3);
intCalcToDate = this.asOnDate;
intCalcType: BondType | '' = ''; intCalcType: BondType | '' = '';
intCalcScript = ''; intCalcScript = '';
intCalcDealNo = ''; intCalcDealNo = '';
intCalcAll = false; intCalcAll = false;
intCalcGlCode = INTEREST_INCOME_GL_BY_TYPE.GOVT_SECURITY;
readonly interestIncomeGlOptions = INTEREST_INCOME_GL_MASTER;
// Write-off // Write-off
writeoffCertNo = ''; writeoffCertNo = '';
@@ -645,13 +658,15 @@ export class BondInvestmentComponent {
} }
// -------------------- Interest calculation -------------------- // -------------------- Interest calculation --------------------
/** Holdings the Interest Calculation "Process" run would cover, given the filters. */ /** Holdings the Interest Calculation "Process" run would cover, given the filters. A
* Non-Performing holding is always excluded — same accrual-basis rule as the Investment
* Interest module (it's still collectible on cash basis via Interest Receive there). */
get intCalcTargets(): InvestmentHolding[] { get intCalcTargets(): InvestmentHolding[] {
const base = this.holdings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h));
if (this.intCalcAll) { if (this.intCalcAll) {
return this.holdings.filter((h) => h.status === 'ACTIVE'); return base;
} }
return this.holdings.filter((h) => { return base.filter((h) => {
if (h.status !== 'ACTIVE') return false;
if (this.intCalcType && h.instrumentType !== this.intCalcType) return false; if (this.intCalcType && h.instrumentType !== this.intCalcType) return false;
if (this.intCalcScript && h.scriptCode !== this.intCalcScript) return false; if (this.intCalcScript && h.scriptCode !== this.intCalcScript) return false;
if (this.intCalcDealNo && h.certNo !== this.intCalcDealNo.trim()) return false; if (this.intCalcDealNo && h.certNo !== this.intCalcDealNo.trim()) return false;
@@ -659,31 +674,62 @@ export class BondInvestmentComponent {
}); });
} }
get intCalcExcludedNpi(): InvestmentHolding[] {
return this.holdings.filter((h) => h.status === 'ACTIVE' && !canAccrueInterest(h));
}
/** Re-suggests the Interest Income GL when the Type filter changes — still overridable. */
onIntCalcTypeChange(): void {
if (this.intCalcType) {
this.intCalcGlCode = INTEREST_INCOME_GL_BY_TYPE[this.intCalcType];
}
}
glName(glCode: string): string {
return this.interestIncomeGlOptions.find((g) => g.glCode === glCode)?.glName ?? glCode;
}
/** Interest accrued strictly within [intCalcFromDate, intCalcToDate] — a period-based
* Interest Calculation & Post run, same basis as the Investment Interest module. */
interestAccrued(h: InvestmentHolding): number { interestAccrued(h: InvestmentHolding): number {
return accruedInterest(h, this.intCalcDate || this.asOnDate); return accruedInterestBetween(h, this.intCalcFromDate || h.purchaseDate, this.intCalcToDate || this.asOnDate);
} }
processInterest(): void { processInterest(): void {
const targets = this.intCalcTargets; const targets = this.intCalcTargets.filter((h) => this.interestAccrued(h) > 0);
if (targets.length === 0) { if (targets.length === 0) {
this.toast('No holdings match the selected filters.'); this.toast('No holdings have interest due to be posted for this period.');
return; return;
} }
const newTxns: InvestmentTxn[] = targets.map((h, i) => ({ const toDate = this.intCalcToDate || this.asOnDate;
certNo: h.certNo, const fromDate = this.intCalcFromDate || toDate;
instrumentType: h.instrumentType, const glCode = this.intCalcGlCode;
txnType: 'INTEREST', const glName = this.glName(glCode);
voucherNo: `INV/INT/${h.certNo}-${this.txns.length + i + 1}`, const newTxns: InvestmentTxn[] = targets.map((h, i) => {
txnDate: this.intCalcDate || this.asOnDate, const amt = this.interestAccrued(h);
amount: this.interestAccrued(h), h.interestPosted = (h.interestPosted ?? 0) + amt;
particulars: `Interest calculated - ${h.scriptName}` h.interestPostedTillDate = toDate;
})); return {
certNo: h.certNo,
instrumentType: h.instrumentType,
txnType: 'INTEREST',
voucherNo: `INV/INT/${h.certNo}-${this.txns.length + i + 1}`,
txnDate: toDate,
amount: amt,
particulars: `Interest calculated (accrual) for period ${fromDate} to ${toDate} - ${h.scriptName}`,
glCode,
glName
};
});
this.txns = [ this.txns = [
...newTxns, ...newTxns,
...this.txns ...this.txns
]; ];
this.intCalcFromDate = toDate;
const total = newTxns.reduce((s, t) => s + t.amount, 0); const total = newTxns.reduce((s, t) => s + t.amount, 0);
this.toast(`Interest processed for ${targets.length} holding(s). Total ₹${total.toLocaleString('en-IN')}.`); this.toast(
`Interest processed for ${targets.length} holding(s) to ${glCode} - ${glName}. Total ₹${total.toLocaleString('en-IN')}.`
);
} }
// -------------------- Sell / Redeem Entry -------------------- // -------------------- Sell / Redeem Entry --------------------
@@ -1092,6 +1138,68 @@ export class BondInvestmentComponent {
return `inv-badge inv-badge--${h.status.toLowerCase()}`; return `inv-badge inv-badge--${h.status.toLowerCase()}`;
} }
// -------------------- Export to Excel (Holdings register) --------------------
async exportExcel(): Promise<void> {
this.generating = true;
try {
await exportWorkbook(
[
{
sheetName: this.instrumentLabel(this.bondType),
title: `${this.instrumentLabel(this.bondType)} Holdings Register`,
subtitle: `As on ${this.asOnDate}`,
headers: [
'Cert No',
'Script',
'Party',
'Units',
'Face Value',
'Rate',
'Purchase Date',
'Maturity Date',
'Accrued Interest',
'Maturity Value',
'Status'
],
widths: [
14,
26,
22,
12,
16,
10,
14,
14,
16,
16,
12
],
rows: this.holdingsForType.map((h) => [
h.certNo,
h.scriptName,
h.party,
h.units,
h.faceValue,
h.interestRate,
h.purchaseDate,
h.maturityDate,
this.accrued(h),
this.maturity(h),
h.status
])
}
],
`${this.instrumentLabel(this.bondType)}-Holdings-${this.asOnDate}`
);
this.toast(`${this.instrumentLabel(this.bondType)} Holdings exported.`);
} catch (err) {
console.error('Bond Holdings export failed', err);
this.toast('Could not generate the workbook — please retry.');
} finally {
this.generating = false;
}
}
private toast(msg: string): void { private toast(msg: string): void {
this.toastMsg = msg; this.toastMsg = msg;
this.toastVisible = true; this.toastVisible = true;
@@ -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();
}
}
@@ -16,6 +16,13 @@
Part-Payment entries. Part-Payment entries.
</p> </p>
</div> </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> </header>
<div class="cctr-tabs"> <div class="cctr-tabs">
@@ -172,8 +179,8 @@
><span>Certificate No.</span><input type="text" [(ngModel)]="purchaseForm.certNo" ><span>Certificate No.</span><input type="text" [(ngModel)]="purchaseForm.certNo"
/></label> /></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Certificate Date</span><input type="text" [(ngModel)]="purchaseForm.certDate" placeholder="DD-MM-YYYY" ><span>Certificate Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.certDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>Sub Type</span> <span>Sub Type</span>
<select [(ngModel)]="purchaseForm.subType"> <select [(ngModel)]="purchaseForm.subType">
@@ -191,12 +198,12 @@
</label> </label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Transaction Date</span ><span>Transaction Date</span
><input type="text" [(ngModel)]="purchaseForm.transactionDate" placeholder="DD-MM-YYYY" ><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.transactionDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Deposit Date *</span ><span>Deposit Date *</span
><input type="text" [(ngModel)]="purchaseForm.depositDate" placeholder="DD-MM-YYYY" ><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.depositDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Deposit Amount *</span><input type="number" [(ngModel)]="purchaseForm.depositAmount" min="0" ><span>Deposit Amount *</span><input type="number" [(ngModel)]="purchaseForm.depositAmount" min="0"
/></label> /></label>
@@ -210,8 +217,8 @@
</label> </label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Interest Date</span ><span>Interest Date</span
><input type="text" [(ngModel)]="purchaseForm.interestDate" placeholder="DD-MM-YYYY" ><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.interestDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Interest Rate *</span><input type="number" [(ngModel)]="purchaseForm.interestRate" min="0" step="0.01" ><span>Interest Rate *</span><input type="number" [(ngModel)]="purchaseForm.interestRate" min="0" step="0.01"
/></label> /></label>
@@ -228,8 +235,8 @@
} @else { } @else {
<label class="inv-form-field" <label class="inv-form-field"
><span>Maturity Date (manual) *</span ><span>Maturity Date (manual) *</span
><input type="text" [(ngModel)]="purchaseForm.manualMaturityDate" placeholder="DD-MM-YYYY" ><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.manualMaturityDate"></mifosx-date-ddmmyyyy
/></label> ></label>
} }
<label class="inv-form-field" <label class="inv-form-field"
><span>Resolution No.</span><input type="text" [(ngModel)]="purchaseForm.resolutionNo" ><span>Resolution No.</span><input type="text" [(ngModel)]="purchaseForm.resolutionNo"
@@ -320,11 +327,25 @@
<span>Certificate No *</span> <span>Certificate No *</span>
<select [(ngModel)]="interestCertNo"> <select [(ngModel)]="interestCertNo">
<option value="">-- Select --</option> <option value="">-- Select --</option>
@for (h of activeHoldings; track h.certNo) { @for (h of interestEligibleHoldings; track h.certNo) {
<option [value]="h.certNo">{{ h.certNo }} — {{ h.party }}</option> <option [value]="h.certNo">{{ h.certNo }} — {{ h.party }}</option>
} }
</select> </select>
</label> </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) { @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>Party</span><input type="text" [value]="h.party" readonly /></label>
<label class="inv-form-field" <label class="inv-form-field"
@@ -334,7 +355,7 @@
><span>Frequency</span><input type="text" [value]="h.interestFreq" readonly ><span>Frequency</span><input type="text" [value]="h.interestFreq" readonly
/></label> /></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Accrued Interest (as on {{ asOnDate }})</span ><span>Interest for Period</span
><input type="text" [value]="'₹' + (interestAccrued(h) | number: '1.2-2')" readonly ><input type="text" [value]="'₹' + (interestAccrued(h) | number: '1.2-2')" readonly
/></label> /></label>
} }
@@ -369,8 +390,8 @@
/></label> /></label>
} }
<label class="inv-form-field" <label class="inv-form-field"
><span>Sell / Premature Date *</span><input type="text" [(ngModel)]="sellDate" placeholder="DD-MM-YYYY" ><span>Sell / Premature Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="sellDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field"><span>Remarks</span><input type="text" [(ngModel)]="sellRemark" /></label> <label class="inv-form-field"><span>Remarks</span><input type="text" [(ngModel)]="sellRemark" /></label>
</div> </div>
<div class="inv-form-actions"> <div class="inv-form-actions">
@@ -447,6 +468,7 @@
<th>Cert No</th> <th>Cert No</th>
<th>Type</th> <th>Type</th>
<th>Particulars</th> <th>Particulars</th>
<th>GL Posted</th>
<th class="num">Amount</th> <th class="num">Amount</th>
</tr> </tr>
</thead> </thead>
@@ -460,6 +482,7 @@
<td>{{ t.certNo }}</td> <td>{{ t.certNo }}</td>
<td>{{ t.txnType }}</td> <td>{{ t.txnType }}</td>
<td>{{ t.particulars }}</td> <td>{{ t.particulars }}</td>
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
<td class="num">₹{{ t.amount | number: '1.2-2' }}</td> <td class="num">₹{{ t.amount | number: '1.2-2' }}</td>
</tr> </tr>
} }
@@ -9,6 +9,7 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import { import {
FD_HOLDINGS, FD_HOLDINGS,
ALL_TXNS, ALL_TXNS,
@@ -17,15 +18,20 @@ import {
FD_TYPE_OPTIONS, FD_TYPE_OPTIONS,
INT_TYPE_OPTIONS, INT_TYPE_OPTIONS,
LIEN_REASON_MASTER, LIEN_REASON_MASTER,
INTEREST_INCOME_GL_MASTER,
INTEREST_INCOME_GL_BY_TYPE,
InvestmentHolding, InvestmentHolding,
InvestmentTxn, InvestmentTxn,
InterestFreq, InterestFreq,
accruedInterest, accruedInterest,
accruedInterestBetween,
canAccrueInterest,
daysToMaturity, daysToMaturity,
maturityAmount, maturityAmount,
addPeriod, addPeriod,
todayDDMMYYYY todayDDMMYYYY
} from '../investment-data'; } from '../investment-data';
import { exportWorkbook } from '../investment-export';
type FdTab = 'holdings' | 'purchase' | 'interest' | 'sell' | 'writeoff' | 'partpayment'; type FdTab = 'holdings' | 'purchase' | 'interest' | 'sell' | 'writeoff' | 'partpayment';
@@ -82,7 +88,8 @@ interface FdPurchaseForm {
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
FormsModule FormsModule,
DateDdmmyyyyPickerComponent
] ]
}) })
export class FdInvestmentComponent { export class FdInvestmentComponent {
@@ -97,12 +104,14 @@ export class FdInvestmentComponent {
readonly fdTypeOptions = FD_TYPE_OPTIONS; readonly fdTypeOptions = FD_TYPE_OPTIONS;
readonly intTypeOptions = INT_TYPE_OPTIONS; readonly intTypeOptions = INT_TYPE_OPTIONS;
readonly lienReasonOptions = LIEN_REASON_MASTER; readonly lienReasonOptions = LIEN_REASON_MASTER;
readonly interestIncomeGlOptions = INTEREST_INCOME_GL_MASTER;
readonly branchOptions = [ readonly branchOptions = [
'HEAD OFFICE', 'HEAD OFFICE',
'BRANCH 1', 'BRANCH 1',
'BRANCH 2' 'BRANCH 2'
]; ];
generating = false;
toastMsg = ''; toastMsg = '';
toastVisible = false; toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined; private toastTimer: ReturnType<typeof setTimeout> | undefined;
@@ -112,6 +121,9 @@ export class FdInvestmentComponent {
searchCertNo = ''; searchCertNo = '';
interestCertNo = ''; interestCertNo = '';
interestFromDate = addPeriod(this.asOnDate, 0, -3);
interestToDate = this.asOnDate;
interestGlCode = INTEREST_INCOME_GL_BY_TYPE.FD;
sellCertNo = ''; sellCertNo = '';
sellDate = this.asOnDate; sellDate = this.asOnDate;
sellRemark = ''; sellRemark = '';
@@ -311,12 +323,24 @@ export class FdInvestmentComponent {
} }
// -------------------- Interest -------------------- // -------------------- 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 { get interestHolding(): InvestmentHolding | undefined {
return this.holdings.find((h) => h.certNo === this.interestCertNo); 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 { interestAccrued(h: InvestmentHolding): number {
return accruedInterest(h, this.asOnDate); 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 { postInterest(): void {
@@ -326,19 +350,34 @@ export class FdInvestmentComponent {
return; return;
} }
const amt = this.interestAccrued(h); 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 = [ this.txns = [
{ {
certNo: h.certNo, certNo: h.certNo,
instrumentType: 'FD', instrumentType: 'FD',
txnType: 'INTEREST', txnType: 'INTEREST',
voucherNo: `INV/INT/FD-${h.certNo}-${this.txns.length + 1}`, voucherNo: `INV/INT/FD-${h.certNo}-${this.txns.length + 1}`,
txnDate: this.asOnDate, txnDate: toDate,
amount: amt, amount: amt,
particulars: `Interest posted - ${h.party} (${h.interestFreq})` particulars: `Interest posted (accrual) for period ${fromDate} to ${toDate} - ${h.party} (${h.interestFreq})`,
glCode,
glName
}, },
...this.txns ...this.txns
]; ];
this.toast(`Interest of ₹${amt.toLocaleString('en-IN')} posted for Certificate ${h.certNo}.`); this.toast(
`Interest of ₹${amt.toLocaleString('en-IN')} posted to ${glCode} - ${glName} for Certificate ${h.certNo}.`
);
this.interestCertNo = ''; this.interestCertNo = '';
} }
@@ -450,6 +489,68 @@ export class FdInvestmentComponent {
return `inv-badge inv-badge--${h.status.toLowerCase()}`; 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 { private toast(msg: string): void {
this.toastMsg = msg; this.toastMsg = msg;
this.toastVisible = true; this.toastVisible = true;
@@ -16,6 +16,11 @@
{{ asOnDate }}. {{ asOnDate }}.
</p> </p>
</div> </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> </header>
<div class="inv-kpis"> <div class="inv-kpis">
@@ -78,6 +83,69 @@
</table> </table>
</div> </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="pg-card">
<div class="pos-section-label">Due-Date Diary — Maturing Within 90 Days</div> <div class="pos-section-label">Due-Date Diary — Maturing Within 90 Days</div>
@if (maturingSoon.length === 0) { @if (maturingSoon.length === 0) {
@@ -144,3 +212,5 @@
</div> </div>
</div> </div>
</div> </div>
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
@@ -11,13 +11,16 @@ import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { import {
ALL_HOLDINGS, ALL_HOLDINGS,
ALLOCATION_TARGET_IS_RBI_LINKED,
INSTRUMENT_LABELS, INSTRUMENT_LABELS,
InstrumentType, InstrumentType,
InvestmentHolding, InvestmentHolding,
TARGET_ALLOCATION_PCT,
accruedInterest, accruedInterest,
daysToMaturity, daysToMaturity,
maturityAmount maturityAmount
} from '../investment-data'; } from '../investment-data';
import { exportWorkbook } from '../investment-export';
interface InstrumentSummary { interface InstrumentSummary {
type: InstrumentType; type: InstrumentType;
@@ -29,6 +32,19 @@ interface InstrumentSummary {
maturityValue: 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 /** 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. */ * hand-duplicated, so it can never drift from the per-instrument screens or Reports. */
@Component({ @Component({
@@ -46,6 +62,10 @@ interface InstrumentSummary {
}) })
export class InvestmentDashboardComponent { export class InvestmentDashboardComponent {
asOnDate = ''; asOnDate = '';
generating = false;
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
private readonly types: Array<{ type: InstrumentType; route: string }> = [ private readonly types: Array<{ type: InstrumentType; route: string }> = [
{ type: 'FD', route: '/investment/fd' }, { type: 'FD', route: '/investment/fd' },
{ type: 'GOVT_SECURITY', route: '/investment/bonds' }, { type: 'GOVT_SECURITY', route: '/investment/bonds' },
@@ -79,6 +99,31 @@ export class InvestmentDashboardComponent {
}); });
} }
/** 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 { get totalHoldings(): number {
return ALL_HOLDINGS.length; return ALL_HOLDINGS.length;
} }
@@ -117,4 +162,88 @@ export class InvestmentDashboardComponent {
instrumentLabel(type: InstrumentType): string { instrumentLabel(type: InstrumentType): string {
return INSTRUMENT_LABELS[type]; 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);
}
} }
+457 -5
View File
@@ -22,6 +22,7 @@ export type InterestFreq = 'MONTHLY' | 'QUARTERLY' | 'HALF_YEARLY' | 'YEARLY' |
export type TxnType = export type TxnType =
| 'PURCHASE' | 'PURCHASE'
| 'INTEREST' | 'INTEREST'
| 'INTEREST_RECEIVED'
| 'DIVIDEND' | 'DIVIDEND'
| 'SELL' | 'SELL'
| 'REDEEM' | 'REDEEM'
@@ -38,6 +39,61 @@ export const INSTRUMENT_LABELS: Record<InstrumentType, string> = {
SHARE: 'Share' SHARE: 'Share'
}; };
/** RBI investment-provisioning classification. A holding with no `provisionStatus` (or
* explicitly 'STANDARD') is performing normally. Once classified Sub-Standard/Doubtful/Loss —
* a Non-Performing Investment (NPI), the loan-book equivalent of an NPA — interest can no
* longer be recognised on accrual basis (see canAccrueInterest()) and a provision must be held
* against the investment (see provisionRequiredInv()). */
export type InvestmentProvisionStatus = 'STANDARD' | 'SUB_STANDARD' | 'DOUBTFUL' | 'LOSS';
export const PROVISION_STATUS_LABELS: Record<InvestmentProvisionStatus, string> = {
STANDARD: 'Standard',
SUB_STANDARD: 'Sub-Standard (NPI)',
DOUBTFUL: 'Doubtful (NPI)',
LOSS: 'Loss (NPI)'
};
/**
* Portfolio-policy target — % of the total investment corpus each instrument type should hold.
* Compared against actual holdings on the Dashboard so a treasury desk can see at a glance where
* the book has drifted from policy. Four of these six trace back to real RBI rules for Urban
* Co-operative Banks (RBI Master Direction/Circular — Investment Portfolio of Primary (Urban)
* Co-operative Banks):
* • SLR: UCBs must hold 18% of NDTL in approved securities — predominantly Central/State Govt
* securities (and SLR-eligible State Development Loan bonds). A well-run treasury holds a
* safety buffer above the bare floor, so Govt Security dominates the book far beyond the 18%
* minimum — it isn't itself an upper target, just the reason this bucket is the largest.
* • Non-SLR ceiling: a UCB's Non-SLR investments must not exceed 10% of its total deposits as
* on 31 March of the previous year — a hard regulatory cap, not a board choice.
* • Mutual Funds: RBI permits UCBs to invest only in debt-oriented schemes of SEBI-registered
* mutual funds, in strictly limited amounts — equity/balanced schemes are not permitted.
* • Shares: RBI does not permit UCBs to invest in equity shares of companies as a general
* investment class; any "Share" holding is essentially the (small, largely static) shares a
* UCB holds in affiliated co-operative institutions, not a discretionary trading book.
* Fixed Deposit (treasury placements with other banks) and Bond (a mix of SLR-eligible SDL and
* Non-SLR PSU/FI bonds) have no RBI-mandated percentage of their own — their split here is a
* board-approved policy choice within the room RBI's SLR/Non-SLR framework leaves.
*/
export const TARGET_ALLOCATION_PCT: Record<InstrumentType, number> = {
GOVT_SECURITY: 55,
BOND: 18,
FD: 15,
NON_SLR: 7,
MUTUAL_FUND: 3,
SHARE: 2
};
/** True for the instrument types whose target traces to an actual RBI rule (SLR floor / Non-SLR
* ceiling) rather than a board-only policy choice — see TARGET_ALLOCATION_PCT's doc comment. */
export const ALLOCATION_TARGET_IS_RBI_LINKED: Record<InstrumentType, boolean> = {
GOVT_SECURITY: true,
BOND: false,
FD: false,
NON_SLR: true,
MUTUAL_FUND: true,
SHARE: true
};
export interface InvestmentHolding { export interface InvestmentHolding {
certNo: string; certNo: string;
instrumentType: InstrumentType; instrumentType: InstrumentType;
@@ -57,6 +113,16 @@ export interface InvestmentHolding {
crToAc: string; crToAc: string;
brokerCode: string; brokerCode: string;
status: HoldingStatus; status: HoldingStatus;
/** Absent/'STANDARD' for a healthy holding. Drives canAccrueInterest() and provisioning. */
provisionStatus?: InvestmentProvisionStatus;
/** Cumulative interest posted (booked as an accrual receivable/income entry) to date. */
interestPosted?: number;
/** Cumulative interest actually received in cash to date. */
interestReceived?: number;
/** DD-MM-YYYY through which interest has already been calculated & posted. accruedInterestBetween()
* never re-counts days up to this date, so re-running a Calculation & Post for an overlapping
* or repeated period always yields 0 instead of double-posting the same interest. */
interestPostedTillDate?: string;
} }
export interface InvestmentTxn { export interface InvestmentTxn {
@@ -67,6 +133,10 @@ export interface InvestmentTxn {
txnDate: string; txnDate: string;
amount: number; amount: number;
particulars: string; particulars: string;
/** GL account this entry was posted/received against — the Interest Income GL for a Post,
* the Cash/Bank GL for a Receive. Absent on older/seeded entries that predate GL selection. */
glCode?: string;
glName?: string;
} }
// -------------------- Date / interest helpers -------------------- // -------------------- Date / interest helpers --------------------
@@ -109,6 +179,75 @@ export function daysToMaturity(h: InvestmentHolding, asOnStr: string): number {
return Math.round((mat - asOn) / (24 * 3600 * 1000)); return Math.round((mat - asOn) / (24 * 3600 * 1000));
} }
// -------------------- Provisioning & interest-lifecycle helpers --------------------
export function isNpi(h: InvestmentHolding): boolean {
return (h.provisionStatus ?? 'STANDARD') !== 'STANDARD';
}
export function provisionRatePctInv(status: InvestmentProvisionStatus): number {
switch (status) {
case 'STANDARD':
return 0;
case 'SUB_STANDARD':
return 25;
case 'DOUBTFUL':
return 50;
case 'LOSS':
return 100;
}
}
/** Provision required against the investment itself (principal), same idea as NPA provisioning
* on a loan book — computed on face value since that is this module's carrying-value figure. */
export function provisionRequiredInv(h: InvestmentHolding): number {
return Math.round(h.faceValue * (provisionRatePctInv(h.provisionStatus ?? 'STANDARD') / 100));
}
/** Interest can only be recognised on an accrual basis (posted as a receivable/income entry)
* while the investment is Standard. Once it is classified Non-Performing (Sub-Standard/
* Doubtful/Loss), RBI income-recognition norms require interest to move to cash basis — i.e.
* it is recognised only when actually received, never posted as an accrual receivable. */
export function canAccrueInterest(h: InvestmentHolding): boolean {
return !isNpi(h);
}
/** Outstanding "Interest Receivable" GL balance — interest already posted (booked as income)
* but not yet actually received in cash. Always 0 for an NPI holding, since nothing is ever
* posted as a receivable on one. */
export function interestReceivableBalance(h: InvestmentHolding): number {
if (!canAccrueInterest(h)) {
return 0;
}
return Math.max(0, (h.interestPosted ?? 0) - (h.interestReceived ?? 0));
}
/** For an NPI holding, interest keeps accruing notionally but is held in memo/suspense — never
* posted as an asset or recognised as income — until it is actually collected in cash. */
export function suspenseInterest(h: InvestmentHolding, asOnStr: string): number {
if (canAccrueInterest(h)) {
return 0;
}
return Math.max(0, accruedInterest(h, asOnStr) - (h.interestReceived ?? 0));
}
/** Interest accrued strictly within [fromStr, toStr], clipped to the holding's own
* purchase→maturity window — the basis for a period-based Interest Calculation & Post run
* (as opposed to accruedInterest(), which is always measured from purchase date). Returns 0
* for a Non-Performing holding, same accrual-basis rule as accruedInterest(). */
export function accruedInterestBetween(h: InvestmentHolding, fromStr: string, toStr: string): number {
if (!canAccrueInterest(h)) {
return 0;
}
const purchase = parseDDMMYYYY(h.purchaseDate).getTime();
const maturity = parseDDMMYYYY(h.maturityDate).getTime();
const postedTill = h.interestPostedTillDate ? parseDDMMYYYY(h.interestPostedTillDate).getTime() : purchase;
const from = Math.max(parseDDMMYYYY(fromStr).getTime(), purchase, postedTill);
const to = Math.min(parseDDMMYYYY(toStr).getTime(), maturity);
const days = Math.max(0, (to - from) / (24 * 3600 * 1000));
return Math.round(h.faceValue * (h.interestRate / 100) * (days / 365.25));
}
export function formatDDMMYYYY(d: Date): string { export function formatDDMMYYYY(d: Date): string {
return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`; return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
} }
@@ -208,7 +347,9 @@ export const FD_HOLDINGS: InvestmentHolding[] = [
glName: 'FIXED DEPO. CO-OPER. BANK MAIN', glName: 'FIXED DEPO. CO-OPER. BANK MAIN',
crToAc: '310070', crToAc: '310070',
brokerCode: '', brokerCode: '',
status: 'ACTIVE' status: 'ACTIVE',
interestPosted: 42000,
interestReceived: 36000
}, },
{ {
certNo: '15624', certNo: '15624',
@@ -268,7 +409,10 @@ export const FD_HOLDINGS: InvestmentHolding[] = [
glName: 'FIXED DEPO. CO-OPER. BANK MAIN', glName: 'FIXED DEPO. CO-OPER. BANK MAIN',
crToAc: '310070', crToAc: '310070',
brokerCode: '', brokerCode: '',
status: 'ACTIVE' status: 'ACTIVE',
provisionStatus: 'DOUBTFUL',
interestPosted: 115500,
interestReceived: 50000
}, },
{ {
certNo: '15627', certNo: '15627',
@@ -289,6 +433,68 @@ export const FD_HOLDINGS: InvestmentHolding[] = [
crToAc: '310070', crToAc: '310070',
brokerCode: '', brokerCode: '',
status: 'ACTIVE' status: 'ACTIVE'
},
{
certNo: '15628',
instrumentType: 'FD',
scriptCode: 'FD-CUM',
scriptName: 'Cumulative Fixed Deposit',
party: 'Zilla Sahakari Bank Ltd',
subType: 'NON SLR',
faceValue: 1800000,
purchaseRate: 100,
units: 1,
purchaseDate: '20-08-2025',
maturityDate: '20-08-2026',
interestRate: 6.8,
interestFreq: 'CUMULATIVE',
glCode: '320020',
glName: 'FIXED DEPO. CO-OPER. BANK MAIN',
crToAc: '310070',
brokerCode: '',
status: 'ACTIVE'
},
{
certNo: '15629',
instrumentType: 'FD',
scriptCode: 'FD-MIP',
scriptName: 'Monthly Interest Payout FD',
party: 'Vidarbha Cooperative Bank',
subType: 'NON SLR',
faceValue: 2200000,
purchaseRate: 100,
units: 1,
purchaseDate: '25-09-2025',
maturityDate: '25-09-2026',
interestRate: 7.15,
interestFreq: 'MONTHLY',
glCode: '320020',
glName: 'FIXED DEPO. CO-OPER. BANK MAIN',
crToAc: '310070',
brokerCode: '',
status: 'ACTIVE',
interestPosted: 143220,
interestReceived: 130800
},
{
certNo: '15630',
instrumentType: 'FD',
scriptCode: 'FD-CUM',
scriptName: 'Cumulative Fixed Deposit',
party: 'Konkan Cooperative Bank Ltd',
subType: 'NON SLR',
faceValue: 900000,
purchaseRate: 100,
units: 1,
purchaseDate: '28-10-2024',
maturityDate: '28-10-2026',
interestRate: 6.75,
interestFreq: 'YEARLY',
glCode: '320020',
glName: 'FIXED DEPO. CO-OPER. BANK MAIN',
crToAc: '310070',
brokerCode: '',
status: 'ACTIVE'
} }
]; ];
@@ -311,7 +517,9 @@ export const GOVT_SECURITY_HOLDINGS: InvestmentHolding[] = [
glName: 'INVESTMENT IN GOVT. SECURITIES', glName: 'INVESTMENT IN GOVT. SECURITIES',
crToAc: '310070', crToAc: '310070',
brokerCode: 'BRK-002', brokerCode: 'BRK-002',
status: 'ACTIVE' status: 'ACTIVE',
interestPosted: 181500,
interestReceived: 150000
}, },
{ {
certNo: 'GSEC-2002', certNo: 'GSEC-2002',
@@ -351,7 +559,9 @@ export const GOVT_SECURITY_HOLDINGS: InvestmentHolding[] = [
glName: 'INVESTMENT IN GOVT. SECURITIES', glName: 'INVESTMENT IN GOVT. SECURITIES',
crToAc: '310070', crToAc: '310070',
brokerCode: 'BRK-002', brokerCode: 'BRK-002',
status: 'ACTIVE' status: 'ACTIVE',
interestPosted: 67900,
interestReceived: 67900
}, },
{ {
certNo: 'GSEC-2004', certNo: 'GSEC-2004',
@@ -412,6 +622,46 @@ export const GOVT_SECURITY_HOLDINGS: InvestmentHolding[] = [
crToAc: '310070', crToAc: '310070',
brokerCode: 'BRK-002', brokerCode: 'BRK-002',
status: 'ACTIVE' status: 'ACTIVE'
},
{
certNo: 'GSEC-2007',
instrumentType: 'GOVT_SECURITY',
scriptCode: 'TBILL-91D',
scriptName: '91 Days Treasury Bill',
party: 'RBI - SGL',
subType: 'SLR',
faceValue: 2000000,
purchaseRate: 98.2,
units: 20000,
purchaseDate: '15-05-2026',
maturityDate: '13-08-2026',
interestRate: 6.2,
interestFreq: 'CUMULATIVE',
glCode: '310020',
glName: 'INVESTMENT IN GOVT. SECURITIES',
crToAc: '310070',
brokerCode: 'BRK-003',
status: 'ACTIVE'
},
{
certNo: 'GSEC-2008',
instrumentType: 'GOVT_SECURITY',
scriptCode: 'GJ-SDL-2026',
scriptName: '7.85% Gujarat SDL 2026',
party: 'RBI - SGL',
subType: 'SLR',
faceValue: 1800000,
purchaseRate: 100.2,
units: 18000,
purchaseDate: '18-09-2025',
maturityDate: '18-09-2026',
interestRate: 7.85,
interestFreq: 'HALF_YEARLY',
glCode: '310020',
glName: 'INVESTMENT IN GOVT. SECURITIES',
crToAc: '310070',
brokerCode: 'BRK-001',
status: 'ACTIVE'
} }
]; ];
@@ -514,6 +764,48 @@ export const NON_SLR_HOLDINGS: InvestmentHolding[] = [
glName: 'INVESTMENT IN NON SLR SECURITIES', glName: 'INVESTMENT IN NON SLR SECURITIES',
crToAc: '310070', crToAc: '310070',
brokerCode: 'BRK-003', brokerCode: 'BRK-003',
status: 'ACTIVE',
interestPosted: 89400,
interestReceived: 60000
},
{
certNo: 'NSLR-3006',
instrumentType: 'NON_SLR',
scriptCode: 'IRFC-7.55-2026',
scriptName: 'IRFC Bond 7.55% 2026',
party: 'Indian Railway Finance Corp',
subType: 'NON SLR',
faceValue: 1600000,
purchaseRate: 100,
units: 16000,
purchaseDate: '25-08-2025',
maturityDate: '25-08-2026',
interestRate: 7.55,
interestFreq: 'YEARLY',
glCode: '310030',
glName: 'INVESTMENT IN NON SLR SECURITIES',
crToAc: '310070',
brokerCode: 'BRK-004',
status: 'ACTIVE'
},
{
certNo: 'NSLR-3007',
instrumentType: 'NON_SLR',
scriptCode: 'NHB-7.3-2026',
scriptName: 'NHB Bond 7.30% 2026',
party: 'National Housing Bank',
subType: 'NON SLR',
faceValue: 1000000,
purchaseRate: 100,
units: 10000,
purchaseDate: '10-10-2025',
maturityDate: '10-10-2026',
interestRate: 7.3,
interestFreq: 'YEARLY',
glCode: '310030',
glName: 'INVESTMENT IN NON SLR SECURITIES',
crToAc: '310070',
brokerCode: 'BRK-003',
status: 'ACTIVE' status: 'ACTIVE'
} }
]; ];
@@ -597,7 +889,10 @@ export const BOND_HOLDINGS: InvestmentHolding[] = [
glName: 'INVESTMENT IN CORPORATE BONDS', glName: 'INVESTMENT IN CORPORATE BONDS',
crToAc: '310070', crToAc: '310070',
brokerCode: 'BRK-006', brokerCode: 'BRK-006',
status: 'ACTIVE' status: 'ACTIVE',
provisionStatus: 'SUB_STANDARD',
interestPosted: 66000,
interestReceived: 40000
}, },
{ {
certNo: 'BOND-4005', certNo: 'BOND-4005',
@@ -618,6 +913,46 @@ export const BOND_HOLDINGS: InvestmentHolding[] = [
crToAc: '310070', crToAc: '310070',
brokerCode: 'BRK-005', brokerCode: 'BRK-005',
status: 'ACTIVE' status: 'ACTIVE'
},
{
certNo: 'BOND-4006',
instrumentType: 'BOND',
scriptCode: 'CHOLA-8.35-2026',
scriptName: 'Cholamandalam Finance NCD 8.35% 2026',
party: 'Cholamandalam Investment & Finance',
subType: 'CORPORATE BOND',
faceValue: 1400000,
purchaseRate: 100,
units: 14000,
purchaseDate: '15-09-2024',
maturityDate: '15-09-2026',
interestRate: 8.35,
interestFreq: 'YEARLY',
glCode: '310040',
glName: 'INVESTMENT IN CORPORATE BONDS',
crToAc: '310070',
brokerCode: 'BRK-006',
status: 'ACTIVE'
},
{
certNo: 'BOND-4007',
instrumentType: 'BOND',
scriptCode: 'PEL-8.4-2026',
scriptName: 'Piramal Enterprises NCD 8.40% 2026',
party: 'Piramal Enterprises Ltd',
subType: 'CORPORATE BOND',
faceValue: 900000,
purchaseRate: 100,
units: 9000,
purchaseDate: '28-10-2025',
maturityDate: '28-10-2026',
interestRate: 8.4,
interestFreq: 'YEARLY',
glCode: '310040',
glName: 'INVESTMENT IN CORPORATE BONDS',
crToAc: '310070',
brokerCode: 'BRK-005',
status: 'ACTIVE'
} }
]; ];
@@ -721,6 +1056,46 @@ export const MUTUAL_FUND_HOLDINGS: InvestmentHolding[] = [
crToAc: '310070', crToAc: '310070',
brokerCode: '', brokerCode: '',
status: 'ACTIVE' status: 'ACTIVE'
},
{
certNo: 'MF-5006',
instrumentType: 'MUTUAL_FUND',
scriptCode: 'SBI-BPSU',
scriptName: 'SBI Banking & PSU Fund',
party: 'SBI Mutual Fund',
subType: 'DEBT - BANKING & PSU',
faceValue: 700000,
purchaseRate: 18.6,
units: 37634.4,
purchaseDate: '10-09-2025',
maturityDate: '10-09-2026',
interestRate: 7.1,
interestFreq: 'CUMULATIVE',
glCode: '310050',
glName: 'INVESTMENT IN MUTUAL FUNDS',
crToAc: '310070',
brokerCode: '',
status: 'ACTIVE'
},
{
certNo: 'MF-5007',
instrumentType: 'MUTUAL_FUND',
scriptCode: 'ICICI-CB',
scriptName: 'ICICI Pru Corporate Bond Fund',
party: 'ICICI Prudential Mutual Fund',
subType: 'DEBT - CORPORATE BOND',
faceValue: 1100000,
purchaseRate: 24.8,
units: 44354.84,
purchaseDate: '14-07-2026',
maturityDate: '14-07-2027',
interestRate: 7.05,
interestFreq: 'CUMULATIVE',
glCode: '310050',
glName: 'INVESTMENT IN MUTUAL FUNDS',
crToAc: '310070',
brokerCode: '',
status: 'ACTIVE'
} }
]; ];
@@ -824,6 +1199,46 @@ export const SHARE_HOLDINGS: InvestmentHolding[] = [
crToAc: '310070', crToAc: '310070',
brokerCode: '', brokerCode: '',
status: 'ACTIVE' status: 'ACTIVE'
},
{
certNo: 'SH-6006',
instrumentType: 'SHARE',
scriptCode: 'ICICIBANK',
scriptName: 'ICICI Bank Ltd',
party: 'NSE/BSE',
subType: 'EQUITY',
faceValue: 420000,
purchaseRate: 1050,
units: 400,
purchaseDate: '05-03-2026',
maturityDate: '05-03-2036',
interestRate: 2.0,
interestFreq: 'YEARLY',
glCode: '310060',
glName: 'INVESTMENT IN SHARES',
crToAc: '310070',
brokerCode: 'BRK-007',
status: 'ACTIVE'
},
{
certNo: 'SH-6007',
instrumentType: 'SHARE',
scriptCode: 'PDCC',
scriptName: 'Pune District Central Coop Bank Shares',
party: 'Pune District Central Cooperative Bank',
subType: 'CO-OP SHARE',
faceValue: 60000,
purchaseRate: 50,
units: 1200,
purchaseDate: '18-07-2025',
maturityDate: '18-07-2035',
interestRate: 5.0,
interestFreq: 'YEARLY',
glCode: '310060',
glName: 'INVESTMENT IN SHARES',
crToAc: '310070',
brokerCode: '',
status: 'ACTIVE'
} }
]; ];
@@ -952,6 +1367,43 @@ export const ALL_TXNS: InvestmentTxn[] = [
// -------------------- Masters -------------------- // -------------------- Masters --------------------
export interface InvestmentGlOption {
glCode: string;
glName: string;
}
/** Interest Income (Cr) GL per instrument type — the GL an Interest Calculation & Post run
* credits. Selectable on the FD/Govt./Bond Interest Calculation screens instead of being
* hard-wired, so a period's posting can be routed to a different income head when needed. */
export const INTEREST_INCOME_GL_MASTER: InvestmentGlOption[] = [
{ glCode: '410010', glName: 'INTEREST ON GOVT. SECURITIES' },
{ glCode: '410020', glName: 'INTEREST ON NON SLR SECURITIES' },
{ glCode: '410030', glName: 'INTEREST ON CORPORATE BONDS' },
{ glCode: '410040', glName: 'INTEREST ON FIXED DEPOSITS' },
{ glCode: '410050', glName: 'INCOME FROM MUTUAL FUNDS' },
{ glCode: '410060', glName: 'DIVIDEND ON SHARES' }
];
/** Default Interest Income GL suggested per instrument type — pre-fills the selector above,
* which the user can still override before posting. */
export const INTEREST_INCOME_GL_BY_TYPE: Record<InstrumentType, string> = {
GOVT_SECURITY: '410010',
NON_SLR: '410020',
BOND: '410030',
FD: '410040',
MUTUAL_FUND: '410050',
SHARE: '410060'
};
/** Cash/Bank (Dr) GL credited when an Interest Receive entry is booked — the account the cash
* actually lands in. Selectable on the Interest Receive screen. */
export const CASH_BANK_GL_MASTER: InvestmentGlOption[] = [
{ glCode: '100010', glName: 'CASH IN HAND' },
{ glCode: '110010', glName: 'RBI CURRENT ACCOUNT' },
{ glCode: '110020', glName: 'SBI CURRENT ACCOUNT' },
{ glCode: '110030', glName: 'HDFC BANK CURRENT ACCOUNT' }
];
export interface ScriptMaster { export interface ScriptMaster {
scriptCode: string; scriptCode: string;
scriptName: string; scriptName: string;
+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);
}
@@ -12,20 +12,33 @@
<div class="pg-eyebrow">TREASURY · INVESTMENT · INTEREST</div> <div class="pg-eyebrow">TREASURY · INVESTMENT · INTEREST</div>
<h1 class="pg-title">Investment Interest</h1> <h1 class="pg-title">Investment Interest</h1>
<p class="pg-sub"> <p class="pg-sub">
FD and Govt./Bond interest calculation batch runs, plus the Government Bond Interest, FD Interest and FD Interest Receivable, FD and Govt./Bond interest calculation-and-post batch runs, Interest Receive, plus the
registers. Government Bond Interest, FD Interest and FD registers.
</p> </p>
</div> </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> </header>
<div class="cctr-tabs"> <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 <button
type="button" type="button"
class="pg-btn pg-btn--ghost" class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'fdCalc'" [class.pg-btn--active]="activeTab === 'fdCalc'"
(click)="setTab('fdCalc')" (click)="setTab('fdCalc')"
> >
01 FD Interest Calculation 01 FD Interest Calculation &amp; Post
</button> </button>
<button <button
type="button" type="button"
@@ -33,7 +46,15 @@
[class.pg-btn--active]="activeTab === 'bondCalc'" [class.pg-btn--active]="activeTab === 'bondCalc'"
(click)="setTab('bondCalc')" (click)="setTab('bondCalc')"
> >
02 Govt./Bond Int. Calculation 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>
<button <button
type="button" type="button"
@@ -61,14 +82,113 @@
</button> </button>
</div> </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 --> <!-- 01 FD INTEREST CALCULATION -->
@if (activeTab === 'fdCalc') { @if (activeTab === 'fdCalc') {
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">Investment - FD Interest Calculation</div> <div class="pos-section-label">Investment - FD Interest Calculation &amp; Post</div>
<div class="inv-form-grid"> <div class="inv-form-grid">
<label class="inv-form-field" <label class="inv-form-field"
><span>Date</span><input type="text" [(ngModel)]="fdCalcDate" placeholder="DD-MM-YYYY" ><span>From Date</span><mifosx-date-ddmmyyyy [(ngModel)]="fdCalcFromDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field"
><span>To Date</span><mifosx-date-ddmmyyyy [(ngModel)]="fdCalcToDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>Certificate No</span> <span>Certificate No</span>
<select [(ngModel)]="fdCalcCertNo"> <select [(ngModel)]="fdCalcCertNo">
@@ -81,13 +201,21 @@
<label class="inv-form-field" <label class="inv-form-field"
><span>ALL</span><input type="checkbox" [(ngModel)]="fdCalcAll" style="height: 18px; width: 18px" ><span>ALL</span><input type="checkbox" [(ngModel)]="fdCalcAll" style="height: 18px; width: 18px"
/></label> /></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" <label class="inv-form-field"
><span>Total Interest to Post</span ><span>Total Interest to Post</span
><input type="text" [value]="'₹' + (fdCalcTotal | number: '1.2-2')" readonly ><input type="text" [value]="'₹' + (fdCalcTotal | number: '1.2-2')" readonly
/></label> /></label>
</div> </div>
<div class="inv-form-actions"> <div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="processFdInterest()">Process</button> <button type="button" class="pg-btn pg-btn--primary" (click)="processFdInterest()">Calculate &amp; Post</button>
</div> </div>
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
@@ -125,20 +253,29 @@
</tr> </tr>
</tfoot> </tfoot>
</table> </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> </div>
} }
<!-- 02 GOVT./BOND INT. CALCULATION --> <!-- 02 GOVT./BOND INT. CALCULATION -->
@if (activeTab === 'bondCalc') { @if (activeTab === 'bondCalc') {
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">Investment - Govt./Bond Interest Calculation</div> <div class="pos-section-label">Investment - Govt./Bond Interest Calculation &amp; Post</div>
<div class="inv-form-grid"> <div class="inv-form-grid">
<label class="inv-form-field" <label class="inv-form-field"
><span>Date</span><input type="text" [(ngModel)]="bondCalcDate" placeholder="DD-MM-YYYY" ><span>From Date</span><mifosx-date-ddmmyyyy [(ngModel)]="bondCalcFromDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field"
><span>To Date</span><mifosx-date-ddmmyyyy [(ngModel)]="bondCalcToDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>Type</span> <span>Type</span>
<select [(ngModel)]="bondCalcType"> <select [(ngModel)]="bondCalcType" (ngModelChange)="onBondCalcTypeChange()">
<option value="">-- All Types --</option> <option value="">-- All Types --</option>
<option value="GOVT_SECURITY">GOVT SECURITY</option> <option value="GOVT_SECURITY">GOVT SECURITY</option>
<option value="NON_SLR">NON SLR</option> <option value="NON_SLR">NON SLR</option>
@@ -158,13 +295,23 @@
<label class="inv-form-field" <label class="inv-form-field"
><span>ALL</span><input type="checkbox" [(ngModel)]="bondCalcAll" style="height: 18px; width: 18px" ><span>ALL</span><input type="checkbox" [(ngModel)]="bondCalcAll" style="height: 18px; width: 18px"
/></label> /></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" <label class="inv-form-field"
><span>Total Interest to Post</span ><span>Total Interest to Post</span
><input type="text" [value]="'₹' + (bondCalcTotal | number: '1.2-2')" readonly ><input type="text" [value]="'₹' + (bondCalcTotal | number: '1.2-2')" readonly
/></label> /></label>
</div> </div>
<div class="inv-form-actions"> <div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="processBondInterest()">Process</button> <button type="button" class="pg-btn pg-btn--primary" (click)="processBondInterest()">
Calculate &amp; Post
</button>
</div> </div>
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
@@ -204,6 +351,112 @@
</tr> </tr>
</tfoot> </tfoot>
</table> </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> </div>
} }
@@ -219,6 +472,7 @@
<th>Cert No</th> <th>Cert No</th>
<th>Instrument</th> <th>Instrument</th>
<th>Particulars</th> <th>Particulars</th>
<th>GL Posted</th>
<th class="num col-in">Interest Amount</th> <th class="num col-in">Interest Amount</th>
</tr> </tr>
</thead> </thead>
@@ -232,17 +486,18 @@
<td>{{ t.certNo }}</td> <td>{{ t.certNo }}</td>
<td>{{ instrumentLabel(t.instrumentType) }}</td> <td>{{ instrumentLabel(t.instrumentType) }}</td>
<td>{{ t.particulars }}</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> <td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
</tr> </tr>
} @empty { } @empty {
<tr> <tr>
<td colspan="6" class="pg-empty">No bond interest entries recorded yet.</td> <td colspan="7" class="pg-empty">No bond interest entries recorded yet.</td>
</tr> </tr>
} }
</tbody> </tbody>
<tfoot> <tfoot>
<tr class="cctr-total-row"> <tr class="cctr-total-row">
<td colspan="5">Total</td> <td colspan="6">Total</td>
<td class="num">₹{{ bondIntRegisterTotal | number: '1.2-2' }}</td> <td class="num">₹{{ bondIntRegisterTotal | number: '1.2-2' }}</td>
</tr> </tr>
</tfoot> </tfoot>
@@ -261,6 +516,7 @@
<th>Voucher No</th> <th>Voucher No</th>
<th>Cert No</th> <th>Cert No</th>
<th>Particulars</th> <th>Particulars</th>
<th>GL Posted</th>
<th class="num col-in">Interest Amount</th> <th class="num col-in">Interest Amount</th>
</tr> </tr>
</thead> </thead>
@@ -273,17 +529,18 @@
</td> </td>
<td>{{ t.certNo }}</td> <td>{{ t.certNo }}</td>
<td>{{ t.particulars }}</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> <td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
</tr> </tr>
} @empty { } @empty {
<tr> <tr>
<td colspan="5" class="pg-empty">No FD interest entries recorded yet.</td> <td colspan="6" class="pg-empty">No FD interest entries recorded yet.</td>
</tr> </tr>
} }
</tbody> </tbody>
<tfoot> <tfoot>
<tr class="cctr-total-row"> <tr class="cctr-total-row">
<td colspan="4">Total</td> <td colspan="5">Total</td>
<td class="num">₹{{ fdIntRegisterTotal | number: '1.2-2' }}</td> <td class="num">₹{{ fdIntRegisterTotal | number: '1.2-2' }}</td>
</tr> </tr>
</tfoot> </tfoot>
@@ -9,6 +9,7 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import { import {
FD_HOLDINGS, FD_HOLDINGS,
GOVT_SECURITY_HOLDINGS, GOVT_SECURITY_HOLDINGS,
@@ -16,25 +17,50 @@ import {
BOND_HOLDINGS, BOND_HOLDINGS,
ALL_TXNS, ALL_TXNS,
SCRIPT_MASTER, SCRIPT_MASTER,
INTEREST_INCOME_GL_MASTER,
INTEREST_INCOME_GL_BY_TYPE,
CASH_BANK_GL_MASTER,
InvestmentHolding, InvestmentHolding,
InvestmentTxn, InvestmentTxn,
InstrumentType, InstrumentType,
InvestmentProvisionStatus,
INSTRUMENT_LABELS, INSTRUMENT_LABELS,
PROVISION_STATUS_LABELS,
accruedInterest, accruedInterest,
accruedInterestBetween,
addPeriod,
daysToMaturity, daysToMaturity,
maturityAmount, maturityAmount,
todayDDMMYYYY todayDDMMYYYY,
isNpi,
canAccrueInterest,
interestReceivableBalance,
suspenseInterest,
provisionRequiredInv
} from '../investment-data'; } from '../investment-data';
import { exportWorkbook, ExportSheetSpec } from '../investment-export';
type InterestTab = 'fdCalc' | 'bondCalc' | 'bondIntRegister' | 'fdIntRegister' | 'fdRegister'; type InterestTab =
| 'receivable'
| 'fdCalc'
| 'bondCalc'
| 'receive'
| 'bondIntRegister'
| 'fdIntRegister'
| 'fdRegister';
/** /**
* The legacy "Interest" top-menu for Investment — 5 screens: * The legacy "Interest" top-menu for Investment — plus the full accrual lifecycle: Interest
* 01 FD Interest Calculation, 02 Govt./Bond Int. Calculation (both batch "Process" runs), * Receivable, 01 FD Interest Calculation, 02 Govt./Bond Int. Calculation, Interest Receive,
* 03 Government Bond Int Register, 04 FD Interest Register, 05 FD Register (listings). * 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, * 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. * 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({ @Component({
selector: 'mifosx-investment-interest', selector: 'mifosx-investment-interest',
@@ -46,11 +72,12 @@ type InterestTab = 'fdCalc' | 'bondCalc' | 'bondIntRegister' | 'fdIntRegister' |
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
FormsModule FormsModule,
DateDdmmyyyyPickerComponent
] ]
}) })
export class InvestmentInterestComponent { export class InvestmentInterestComponent {
activeTab: InterestTab = 'fdCalc'; activeTab: InterestTab = 'receivable';
asOnDate = todayDDMMYYYY(); asOnDate = todayDDMMYYYY();
fdHoldings: InvestmentHolding[] = [...FD_HOLDINGS]; fdHoldings: InvestmentHolding[] = [...FD_HOLDINGS];
@@ -62,22 +89,35 @@ export class InvestmentInterestComponent {
txns: InvestmentTxn[] = [...ALL_TXNS]; txns: InvestmentTxn[] = [...ALL_TXNS];
readonly scriptOptions = SCRIPT_MASTER; 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 = ''; toastMsg = '';
toastVisible = false; toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined; private toastTimer: ReturnType<typeof setTimeout> | undefined;
// 01 FD Interest Calculation // 01 FD Interest Calculation — posts interest accrued strictly within [fromDate, toDate]
fdCalcDate = this.asOnDate; fdCalcFromDate = addPeriod(this.asOnDate, 0, -3);
fdCalcToDate = this.asOnDate;
fdCalcCertNo = ''; fdCalcCertNo = '';
fdCalcAll = false; fdCalcAll = false;
fdCalcGlCode = INTEREST_INCOME_GL_BY_TYPE.FD;
// 02 Govt./Bond Int. Calculation // 02 Govt./Bond Int. Calculation — posts interest accrued strictly within [fromDate, toDate]
bondCalcDate = this.asOnDate; bondCalcFromDate = addPeriod(this.asOnDate, 0, -3);
bondCalcToDate = this.asOnDate;
bondCalcType: InstrumentType | '' = ''; bondCalcType: InstrumentType | '' = '';
bondCalcScript = ''; bondCalcScript = '';
bondCalcDealNo = ''; bondCalcDealNo = '';
bondCalcAll = false; 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 // Register filters
registerFromDate = ''; registerFromDate = '';
@@ -103,55 +143,121 @@ export class InvestmentInterestComponent {
return daysToMaturity(h, this.asOnDate); 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 -------------------- // -------------------- 01 FD Interest Calculation --------------------
get fdCalcTargets(): InvestmentHolding[] { get fdCalcTargets(): InvestmentHolding[] {
const base = this.fdHoldings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h));
if (this.fdCalcAll) { if (this.fdCalcAll) {
return this.fdHoldings.filter((h) => h.status === 'ACTIVE'); return base;
} }
return this.fdHoldings.filter( return base.filter((h) => !this.fdCalcCertNo || h.certNo === this.fdCalcCertNo);
(h) => h.status === 'ACTIVE' && (!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 { fdInterest(h: InvestmentHolding): number {
return accruedInterest(h, this.fdCalcDate || this.asOnDate); return accruedInterestBetween(h, this.fdCalcFromDate || h.purchaseDate, this.fdCalcToDate || this.asOnDate);
} }
get fdCalcTotal(): number { get fdCalcTotal(): number {
return this.fdCalcTargets.reduce((s, h) => s + this.fdInterest(h), 0); 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 { processFdInterest(): void {
const targets = this.fdCalcTargets; const targets = this.fdCalcTargets.filter((h) => this.fdInterest(h) > 0);
if (targets.length === 0) { if (targets.length === 0) {
this.toast('No FD holdings match the selected filters.'); this.toast('No FD holdings have interest due to be posted for this period.');
return; return;
} }
const newTxns: InvestmentTxn[] = targets.map((h, i) => ({ const toDate = this.fdCalcToDate || this.asOnDate;
certNo: h.certNo, const fromDate = this.fdCalcFromDate || toDate;
instrumentType: 'FD', const glCode = this.fdCalcGlCode;
txnType: 'INTEREST', const glName = this.glName(glCode);
voucherNo: `INV/FD-INT/${(this.fdCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`, const newTxns: InvestmentTxn[] = targets.map((h, i) => {
txnDate: this.fdCalcDate || this.asOnDate, const amt = this.fdInterest(h);
amount: this.fdInterest(h), h.interestPosted = this.posted(h) + amt;
particulars: `FD interest calculated - ${h.party}` 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 = [ this.txns = [
...newTxns, ...newTxns,
...this.txns ...this.txns
]; ];
this.toast( this.toast(
`FD interest processed for ${targets.length} certificate(s). Total ₹${this.fdCalcTotal.toLocaleString('en-IN')}.` `FD interest posted for ${targets.length} certificate(s) to ${glCode} - ${glName}. Total ₹${total.toLocaleString('en-IN')}.`
); );
} }
// -------------------- 02 Govt./Bond Int. Calculation -------------------- // -------------------- 02 Govt./Bond Int. Calculation --------------------
get bondCalcTargets(): InvestmentHolding[] { get bondCalcTargets(): InvestmentHolding[] {
const base = this.bondHoldings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h));
if (this.bondCalcAll) { if (this.bondCalcAll) {
return this.bondHoldings.filter((h) => h.status === 'ACTIVE'); return base;
} }
return this.bondHoldings.filter((h) => { return base.filter((h) => {
if (h.status !== 'ACTIVE') return false;
if (this.bondCalcType && h.instrumentType !== this.bondCalcType) return false; if (this.bondCalcType && h.instrumentType !== this.bondCalcType) return false;
if (this.bondCalcScript && h.scriptCode !== this.bondCalcScript) return false; if (this.bondCalcScript && h.scriptCode !== this.bondCalcScript) return false;
if (this.bondCalcDealNo && h.certNo !== this.bondCalcDealNo.trim()) return false; if (this.bondCalcDealNo && h.certNo !== this.bondCalcDealNo.trim()) return false;
@@ -159,38 +265,142 @@ export class InvestmentInterestComponent {
}); });
} }
/** 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 { bondInterest(h: InvestmentHolding): number {
return accruedInterest(h, this.bondCalcDate || this.asOnDate); return accruedInterestBetween(h, this.bondCalcFromDate || h.purchaseDate, this.bondCalcToDate || this.asOnDate);
} }
get bondCalcTotal(): number { get bondCalcTotal(): number {
return this.bondCalcTargets.reduce((s, h) => s + this.bondInterest(h), 0); 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 { processBondInterest(): void {
const targets = this.bondCalcTargets; const targets = this.bondCalcTargets.filter((h) => this.bondInterest(h) > 0);
if (targets.length === 0) { if (targets.length === 0) {
this.toast('No Govt/Bond holdings match the selected filters.'); this.toast('No Govt/Bond holdings have interest due to be posted for this period.');
return; return;
} }
const newTxns: InvestmentTxn[] = targets.map((h, i) => ({ const toDate = this.bondCalcToDate || this.asOnDate;
certNo: h.certNo, const fromDate = this.bondCalcFromDate || toDate;
instrumentType: h.instrumentType, const glCode = this.bondCalcGlCode;
txnType: 'INTEREST', const glName = this.glName(glCode);
voucherNo: `INV/BOND-INT/${(this.bondCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`, const newTxns: InvestmentTxn[] = targets.map((h, i) => {
txnDate: this.bondCalcDate || this.asOnDate, const amt = this.bondInterest(h);
amount: this.bondInterest(h), h.interestPosted = this.posted(h) + amt;
particulars: `Coupon/interest calculated - ${h.scriptName}` 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 = [ this.txns = [
...newTxns, ...newTxns,
...this.txns ...this.txns
]; ];
this.bondCalcFromDate = toDate;
this.toast( this.toast(
`Interest processed for ${targets.length} holding(s). Total ₹${this.bondCalcTotal.toLocaleString('en-IN')}.` `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 -------------------- // -------------------- 03 Government Bond Int Register --------------------
get bondIntRegister(): InvestmentTxn[] { get bondIntRegister(): InvestmentTxn[] {
return this.txns.filter( return this.txns.filter(
@@ -236,6 +446,309 @@ export class InvestmentInterestComponent {
return this.fdRegister.reduce((s, h) => s + this.accrued(h), 0); 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 { private toast(msg: string): void {
this.toastMsg = msg; this.toastMsg = msg;
this.toastVisible = true; this.toastVisible = true;
@@ -16,6 +16,11 @@
Market Detail masters. Market Detail masters.
</p> </p>
</div> </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> </header>
<div class="cctr-tabs"> <div class="cctr-tabs">
@@ -262,8 +267,8 @@
><span>Contact No</span><input type="text" [(ngModel)]="newBroker.contactNo" ><span>Contact No</span><input type="text" [(ngModel)]="newBroker.contactNo"
/></label> /></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Empanelled On</span><input type="text" [(ngModel)]="newBroker.empanelledOn" placeholder="DD-MM-YYYY" ><span>Empanelled On</span><mifosx-date-ddmmyyyy [(ngModel)]="newBroker.empanelledOn"></mifosx-date-ddmmyyyy
/></label> ></label>
</div> </div>
<div class="inv-form-actions"> <div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="addBroker()">Save</button> <button type="button" class="pg-btn pg-btn--primary" (click)="addBroker()">Save</button>
@@ -344,8 +349,8 @@
<div class="pos-section-label">Add Daily Script Rate</div> <div class="pos-section-label">Add Daily Script Rate</div>
<div class="inv-form-grid"> <div class="inv-form-grid">
<label class="inv-form-field" <label class="inv-form-field"
><span>Rate Date *</span><input type="text" [(ngModel)]="newDailyRate.rateDate" placeholder="DD-MM-YYYY" ><span>Rate Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="newDailyRate.rateDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>Script Code *</span> <span>Script Code *</span>
<select [(ngModel)]="newDailyRate.scriptCode"> <select [(ngModel)]="newDailyRate.scriptCode">
@@ -9,6 +9,7 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import { import {
SCRIPT_MASTER, SCRIPT_MASTER,
BROKER_MASTER, BROKER_MASTER,
@@ -32,6 +33,7 @@ import {
LienReason, LienReason,
InstrumentType InstrumentType
} from '../investment-data'; } from '../investment-data';
import { exportWorkbook, ExportSheetSpec } from '../investment-export';
type MasterTab = type MasterTab =
| 'party' | 'party'
@@ -59,7 +61,8 @@ type MasterTab =
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
FormsModule FormsModule,
DateDdmmyyyyPickerComponent
] ]
}) })
export class InvestmentMastersComponent { export class InvestmentMastersComponent {
@@ -85,6 +88,20 @@ export class InvestmentMastersComponent {
'SHARE' '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 = ''; toastMsg = '';
toastVisible = false; toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined; private toastTimer: ReturnType<typeof setTimeout> | undefined;
@@ -251,6 +268,244 @@ export class InvestmentMastersComponent {
this.toast('Daily script rate added.'); 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 { private toast(msg: string): void {
this.toastMsg = msg; this.toastMsg = msg;
this.toastVisible = true; this.toastVisible = true;
@@ -16,6 +16,11 @@
same holdings/transactions used by the Entry screens. same holdings/transactions used by the Entry screens.
</p> </p>
</div> </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> </header>
<div class="cctr-tabs"> <div class="cctr-tabs">
@@ -692,3 +697,5 @@
</div> </div>
} }
</div> </div>
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
@@ -27,6 +27,7 @@ import {
daysToMaturity, daysToMaturity,
todayDDMMYYYY todayDDMMYYYY
} from '../investment-data'; } from '../investment-data';
import { exportWorkbook, ExportSheetSpec } from '../investment-export';
type ReportGroup = 'balance' | 'ledger' | 'maturity' | 'portfolio'; type ReportGroup = 'balance' | 'ledger' | 'maturity' | 'portfolio';
@@ -50,6 +51,11 @@ export class InvestmentReportsComponent {
activeReport = 'govtSecBalance'; activeReport = 'govtSecBalance';
asOnDate = todayDDMMYYYY(); asOnDate = todayDDMMYYYY();
generating = false;
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
setGroup(group: ReportGroup, defaultReport: string): void { setGroup(group: ReportGroup, defaultReport: string): void {
this.activeGroup = group; this.activeGroup = group;
this.activeReport = defaultReport; this.activeReport = defaultReport;
@@ -244,4 +250,364 @@ export class InvestmentReportsComponent {
get portfolioTotalMaturity(): number { get portfolioTotalMaturity(): number {
return this.portfolioSummary.reduce((s, r) => s + r.maturityValue, 0); 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);
}
} }
+26
View File
@@ -128,6 +128,32 @@
color: #b91c1c; color: #b91c1c;
border: 1px solid #fecaca; 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 { .inv-form-grid {
@@ -15,6 +15,13 @@
Equity and cooperative-society share holdings — Purchase, Dividend, Sell and Write-off entries. Equity and cooperative-society share holdings — Purchase, Dividend, Sell and Write-off entries.
</p> </p>
</div> </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> </header>
<div class="cctr-tabs"> <div class="cctr-tabs">
@@ -131,8 +138,8 @@
/></label> /></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Purchase Date *</span ><span>Purchase Date *</span
><input type="text" [(ngModel)]="purchaseForm.purchaseDate" placeholder="DD-MM-YYYY" ><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.purchaseDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Broker Code</span><input type="text" [(ngModel)]="purchaseForm.brokerCode" ><span>Broker Code</span><input type="text" [(ngModel)]="purchaseForm.brokerCode"
/></label> /></label>
@@ -9,7 +9,9 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; 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 { SHARE_HOLDINGS, ALL_TXNS, InvestmentHolding, InvestmentTxn, todayDDMMYYYY } from '../investment-data';
import { exportWorkbook } from '../investment-export';
type ShareTab = 'holdings' | 'purchase' | 'dividend' | 'sell' | 'writeoff'; type ShareTab = 'holdings' | 'purchase' | 'dividend' | 'sell' | 'writeoff';
@@ -40,7 +42,8 @@ interface SharePurchaseForm {
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
FormsModule FormsModule,
DateDdmmyyyyPickerComponent
] ]
}) })
export class ShareInvestmentComponent { export class ShareInvestmentComponent {
@@ -50,6 +53,7 @@ export class ShareInvestmentComponent {
holdings: InvestmentHolding[] = [...SHARE_HOLDINGS]; holdings: InvestmentHolding[] = [...SHARE_HOLDINGS];
txns: InvestmentTxn[] = ALL_TXNS.filter((t) => t.instrumentType === 'SHARE'); txns: InvestmentTxn[] = ALL_TXNS.filter((t) => t.instrumentType === 'SHARE');
generating = false;
toastMsg = ''; toastMsg = '';
toastVisible = false; toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined; private toastTimer: ReturnType<typeof setTimeout> | undefined;
@@ -252,6 +256,59 @@ export class ShareInvestmentComponent {
return `inv-badge inv-badge--${h.status.toLowerCase()}`; 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 { private toast(msg: string): void {
this.toastMsg = msg; this.toastMsg = msg;
this.toastVisible = true; this.toastVisible = true;
@@ -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);
}
}
+384 -22
View File
@@ -79,6 +79,26 @@ export interface RecoveryTxn {
mode: string; 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 { export interface LoanAccount {
accountNo: string; accountNo: string;
customerName: string; customerName: string;
@@ -88,6 +108,7 @@ export interface LoanAccount {
productCode: string; productCode: string;
productName: string; productName: string;
secured: boolean; secured: boolean;
repaymentType: RepaymentType;
sanctionedAmount: number; sanctionedAmount: number;
disbursementDate: string; disbursementDate: string;
interestRate: number; interestRate: number;
@@ -96,11 +117,27 @@ export interface LoanAccount {
overdueDays: number; overdueDays: number;
overdueDaysQuarterAgo: number; overdueDaysQuarterAgo: number;
collateralValue: 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; lastPaymentDate: string;
isLossAsset: boolean; isLossAsset: boolean;
isRestructured: boolean; isRestructured: boolean;
provisionCoverageRatio: number; provisionCoverageRatio: number;
recoveries: RecoveryTxn[]; 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. */ /** '—' for Standard/SMA accounts — only NPA accounts are actually assigned a reason. */
npaReason: string; npaReason: string;
/** '—' for Standard accounts — SMA and NPA accounts get a recovery officer assigned for follow-up. */ /** '—' for Standard accounts — SMA and NPA accounts get a recovery officer assigned for follow-up. */
@@ -174,6 +211,17 @@ export const BRANCHES: BranchMaster[] = [
{ code: 'BR08', name: 'Kolhapur 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 { interface ProductMaster {
code: string; code: string;
name: string; name: string;
@@ -182,6 +230,11 @@ interface ProductMaster {
maxAmt: number; maxAmt: number;
minRate: number; minRate: number;
maxRate: 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[] = [ export const LOAN_PRODUCTS: ProductMaster[] = [
{ {
@@ -191,15 +244,87 @@ export const LOAN_PRODUCTS: ProductMaster[] = [
minAmt: 500000, minAmt: 500000,
maxAmt: 5000000, maxAmt: 5000000,
minRate: 11, minRate: 11,
maxRate: 13.5 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: '02', name: 'Term Loan - MSME', secured: true, minAmt: 300000, maxAmt: 3000000, minRate: 11, maxRate: 14 },
{ code: '03', name: 'Housing Loan', secured: true, minAmt: 1000000, maxAmt: 4000000, minRate: 8.5, maxRate: 10.5 },
{ code: '04', name: 'Vehicle Loan', secured: true, minAmt: 300000, maxAmt: 1500000, minRate: 9, maxRate: 11.5 },
{ code: '05', name: 'Gold Loan', secured: true, minAmt: 50000, maxAmt: 1000000, minRate: 9, maxRate: 12 },
{ code: '06', name: 'Kisan Credit Card', secured: true, minAmt: 100000, maxAmt: 500000, minRate: 7, maxRate: 9 },
{ code: '07', name: 'Personal Loan', secured: false, minAmt: 50000, maxAmt: 500000, minRate: 13, maxRate: 16 },
{ code: '08', name: 'Education Loan', secured: false, minAmt: 200000, maxAmt: 1500000, minRate: 10, maxRate: 12 },
{ {
code: '09', code: '09',
name: 'SHG / Micro Finance Loan', name: 'SHG / Micro Finance Loan',
@@ -207,7 +332,9 @@ export const LOAN_PRODUCTS: ProductMaster[] = [
minAmt: 20000, minAmt: 20000,
maxAmt: 200000, maxAmt: 200000,
minRate: 12, minRate: 12,
maxRate: 14 maxRate: 14,
repaymentType: 'EMI',
tenureMonths: 24
}, },
{ {
code: '10', code: '10',
@@ -216,7 +343,9 @@ export const LOAN_PRODUCTS: ProductMaster[] = [
minAmt: 500000, minAmt: 500000,
maxAmt: 2500000, maxAmt: 2500000,
minRate: 11, minRate: 11,
maxRate: 13 maxRate: 13,
repaymentType: 'OVERDRAWN',
tenureMonths: 12
} }
]; ];
@@ -345,6 +474,113 @@ function pickBucket(): StageBucket {
return BUCKETS[0]; 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 /** 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. */ * advanced/stressed the classification, the more likely the bank has already escalated. */
function legalActionFor(stage: NpaStage): LegalActionStatus { function legalActionFor(stage: NpaStage): LegalActionStatus {
@@ -398,6 +634,26 @@ function generateAccounts(count: number): LoanAccount[] {
const overdueAmount = round(outstandingBalance * overdueFrac); const overdueAmount = round(outstandingBalance * overdueFrac);
const collateralValue = product.secured ? round(sanctionedAmount * between(0.85, 1.25)) : 0; 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 npaReason = isNpaStage(stressed) ? pick(NPA_REASONS) : '—';
const recoveryOfficer = isNpaStage(stressed) || isSmaStage(stressed) ? pick(RECOVERY_OFFICERS) : '—'; const recoveryOfficer = isNpaStage(stressed) || isSmaStage(stressed) ? pick(RECOVERY_OFFICERS) : '—';
const legalActionStatus = legalActionFor(stressed); const legalActionStatus = legalActionFor(stressed);
@@ -439,6 +695,8 @@ function generateAccounts(count: number): LoanAccount[] {
? fmt(latestRecoveryDate) ? fmt(latestRecoveryDate)
: formulaLastPaymentDate; : formulaLastPaymentDate;
const collectionTimeline = buildCollectionTimeline(overdueDays, recoveries, today);
const seq = String(1000 + i).padStart(6, '0'); const seq = String(1000 + i).padStart(6, '0');
accounts.push({ accounts.push({
accountNo: `1${branch.code.slice(2)}${product.code}${seq}`, accountNo: `1${branch.code.slice(2)}${product.code}${seq}`,
@@ -449,6 +707,7 @@ function generateAccounts(count: number): LoanAccount[] {
productCode: product.code, productCode: product.code,
productName: product.name, productName: product.name,
secured: product.secured, secured: product.secured,
repaymentType: product.repaymentType,
sanctionedAmount, sanctionedAmount,
disbursementDate, disbursementDate,
interestRate, interestRate,
@@ -457,11 +716,16 @@ function generateAccounts(count: number): LoanAccount[] {
overdueDays, overdueDays,
overdueDaysQuarterAgo, overdueDaysQuarterAgo,
collateralValue, collateralValue,
collateralMarketValue,
emiAmount,
drawingPower,
maturityDate,
lastPaymentDate, lastPaymentDate,
isLossAsset, isLossAsset,
isRestructured, isRestructured,
provisionCoverageRatio: +between(0.75, 1.1).toFixed(2), provisionCoverageRatio: +between(0.75, 1.1).toFixed(2),
recoveries, recoveries,
collectionTimeline,
npaReason, npaReason,
recoveryOfficer, recoveryOfficer,
legalActionStatus legalActionStatus
@@ -553,14 +817,23 @@ export function provisionHeld(a: LoanAccount): number {
return Math.min(a.outstandingBalance, round(provisionRequired(a) * a.provisionCoverageRatio)); return Math.min(a.outstandingBalance, round(provisionRequired(a) * a.provisionCoverageRatio));
} }
/** Interest income de-recognised because the account has been NPA for longer than 90 days. */ /** Days elapsed since the account first crossed 90 days overdue (i.e. became NPA) — 0 for any
export function interestReversed(a: LoanAccount): number { * account that isn't currently NPA. The single source both interest-reversal and NPA-since-date
const stage = currentStage(a); * are built from, so neither can ever quietly drift out of step with the other. */
if (!isNpaStage(stage)) { export function daysSinceNpa(a: LoanAccount): number {
if (!isNpaStage(currentStage(a))) {
return 0; return 0;
} }
const daysSinceNpa = Math.max(0, a.overdueDays - 90); return Math.max(0, a.overdueDays - 90);
return round(a.outstandingBalance * (a.interestRate / 100) * (daysSinceNpa / 365)); }
/** 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, /** Date the account first crossed 90 days overdue (i.e. became NPA) — derived from overdueDays,
@@ -569,14 +842,96 @@ export function npaSinceDate(a: LoanAccount): string {
if (!isNpaStage(currentStage(a))) { if (!isNpaStage(currentStage(a))) {
return '—'; return '—';
} }
const daysSinceNpa = Math.max(0, a.overdueDays - 90); return fmt(addDays(new Date(), -daysSinceNpa(a)));
return fmt(addDays(new Date(), -daysSinceNpa));
} }
export function totalRecovered(a: LoanAccount): number { export function totalRecovered(a: LoanAccount): number {
return a.recoveries.reduce((s, r) => s + r.amount, 0); 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 { export function movementLabel(prev: NpaStage, curr: NpaStage): string {
if (prev === curr) { if (prev === curr) {
return 'No Change'; return 'No Change';
@@ -594,26 +949,33 @@ export function movementLabel(prev: NpaStage, curr: NpaStage): string {
// in-page group/report tab rows, so a menu item and its screen can never // in-page group/report tab rows, so a menu item and its screen can never
// point at different labels. // point at different labels.
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
export type ReportGroupId = 'reports' | 'analysis' | 'classification'; export type ReportGroupId = 'reports' | 'analysis' | 'classification' | 'recovery-mgmt';
export interface ReportMeta { export interface ReportMeta {
id: string; id: string;
group: ReportGroupId; group: ReportGroupId;
label: string; label: string;
} }
export const REPORT_META: ReportMeta[] = [ export const REPORT_META: ReportMeta[] = [
{ id: 'interest', group: 'reports', label: 'NPA Interest' }, { id: 'interest', group: 'reports', label: 'NPA Report' },
{ id: 'status', group: 'reports', label: 'NPA Status Report' }, { id: 'status', group: 'reports', label: 'NPA Status Report' },
{ id: 'recovery', group: 'reports', label: 'NPA Recovery Report' }, { id: 'recovery', group: 'reports', label: 'NPA Recovery Report' },
{ id: 'summary', group: 'reports', label: 'NPA Summary Report' }, { id: 'summary', group: 'reports', label: 'NPA Summary Report' },
{ id: 'account-detail', group: 'analysis', label: 'NPA Account Detail Report' }, { id: 'account-detail', group: 'analysis', label: 'NPA Account Detail Report' },
{ id: 'movement', group: 'analysis', label: 'NPA Movement Report' }, { id: 'movement', group: 'analysis', label: 'NPA Movement Report' },
{ id: 'provision', group: 'analysis', label: 'NPA with Provision and Required Provision' }, { 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: 'probable', group: 'classification', label: 'Probable NPA' },
{ id: 'sma', group: 'classification', label: 'SMA Report' } { 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> = { export const GROUP_LABELS: Record<ReportGroupId, string> = {
reports: 'NPA Reports', reports: 'NPA Reports',
analysis: 'NPA Analysis', analysis: 'NPA Analysis',
classification: 'NPA Classification' classification: 'NPA Classification',
'recovery-mgmt': 'Recovery Management'
}; };
export const DEFAULT_REPORT_ID = 'summary'; export const DEFAULT_REPORT_ID = 'summary';
@@ -38,6 +38,9 @@
<input type="text" [(ngModel)]="asOnDate" placeholder="DD-MM-YYYY" /> <input type="text" [(ngModel)]="asOnDate" placeholder="DD-MM-YYYY" />
</label> </label>
<button type="button" class="pg-btn pg-btn--primary" (click)="generate()">Generate</button> <button type="button" class="pg-btn pg-btn--primary" (click)="generate()">Generate</button>
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
</div> </div>
</header> </header>
@@ -72,7 +75,7 @@
</div> </div>
<div class="cctr-tabs"> <div class="cctr-tabs">
@for (g of ['reports', 'analysis', 'classification']; track g) { @for (g of ['reports', 'analysis', 'classification', 'recovery-mgmt']; track g) {
<button <button
type="button" type="button"
class="pg-btn pg-btn--ghost" class="pg-btn pg-btn--ghost"
@@ -100,7 +103,7 @@
<!-- ===================== 01 NPA INTEREST ===================== --> <!-- ===================== 01 NPA INTEREST ===================== -->
@if (activeReport === 'interest') { @if (activeReport === 'interest') {
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">NPA Interest — Income Reversed on Non-Performing Accounts</div> <div class="pos-section-label">NPA Report — Income Reversed on Non-Performing Accounts</div>
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
<tr> <tr>
@@ -261,16 +264,22 @@
<div class="pos-section-label">Record a Recovery Against an NPA Account</div> <div class="pos-section-label">Record a Recovery Against an NPA Account</div>
<div class="inv-form-grid"> <div class="inv-form-grid">
<label class="inv-form-field"> <label class="inv-form-field">
<span>* NPA Account</span> <span>Account Type</span>
<select [(ngModel)]="newRecovery.accountNo"> <select [ngModel]="recoveryProductFilter" (ngModelChange)="setRecoveryProductFilter($event)">
<option value="">-- Select --</option> <option value="">-- All Account Types --</option>
@for (a of recoveryOptions; track a.accountNo) { @for (p of recoveryProductOptions; track p) {
<option [value]="a.accountNo"> <option [value]="p">{{ p }}</option>
{{ a.accountNo }} — {{ a.customerName }} ({{ stageLabel(stage(a)) }})
</option>
} }
</select> </select>
</label> </label>
<label class="inv-form-field">
<span>* NPA Account</span>
<mifosx-search-select
[options]="recoveryAccountSearchOptions"
[(ngModel)]="newRecovery.accountNo"
placeholder="Search account no. or customer name…"
></mifosx-search-select>
</label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>* Recovery Date</span> <span>* Recovery Date</span>
<input type="text" [(ngModel)]="newRecovery.date" placeholder="DD-MM-YYYY" /> <input type="text" [(ngModel)]="newRecovery.date" placeholder="DD-MM-YYYY" />
@@ -296,45 +305,66 @@
</div> </div>
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label"> <div
NPA Recovery Register — ₹{{ totalRecoveredAmount | number: '1.0-0' }} recovered against ₹{{ class="pos-section-label"
gnpaAmount | number: '1.0-0' style="display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap"
}} >
GNPA outstanding <span>
NPA Recovery Register — ₹{{ totalRecoveredAmount | number: '1.0-0' }} recovered against ₹{{
gnpaAmount | number: '1.0-0'
}}
GNPA outstanding
</span>
@if (recoveryProductFilter) {
<span class="pg-chip">Filtered: {{ recoveryProductFilter }}</span>
}
</div> </div>
<p class="pg-sub" style="padding: 8px 16px 12px">
Target Recovery is the account's contractual due this month (EMI / interest-servicing / bullet amount) — same
basis as the Recovery Management Dashboard.
</p>
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
<tr> <tr>
<th>Date</th> <th>Date</th>
<th>Account Type</th>
<th>NPA Category</th>
<th>Account No</th> <th>Account No</th>
<th>Customer</th> <th>Customer</th>
<th>Branch</th> <th>Branch</th>
<th>Mode</th> <th>Mode</th>
<th class="num col-in">Recovered Amount</th> <th class="num">Target Recovery</th>
<th class="num col-in">Recovered Amount (Actual)</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (r of paged(recoveryRows); track r.accountNo + r.date + r.amount) { @for (r of paged(recoveryRows); track r.accountNo + r.date + r.amount) {
<tr> <tr>
<td>{{ r.date }}</td> <td>{{ r.date }}</td>
<td>{{ r.productName }}</td>
<td>
<span [class]="r.npaCategoryClass">{{ r.npaCategory }}</span>
</td>
<td> <td>
<span class="pg-chip">{{ r.accountNo }}</span> <span class="pg-chip">{{ r.accountNo }}</span>
</td> </td>
<td>{{ r.customerName }}</td> <td>{{ r.customerName }}</td>
<td>{{ r.branchName }}</td> <td>{{ r.branchName }}</td>
<td>{{ r.mode }}</td> <td>{{ r.mode }}</td>
<td class="num">₹{{ r.targetRecovery | number: '1.0-0' }}</td>
<td class="num col-in">₹{{ r.amount | number: '1.0-0' }}</td> <td class="num col-in">₹{{ r.amount | number: '1.0-0' }}</td>
</tr> </tr>
} @empty { } @empty {
<tr> <tr>
<td colspan="6" class="pg-empty">No recoveries recorded yet.</td> <td colspan="9" class="pg-empty">No recoveries recorded yet.</td>
</tr> </tr>
} }
</tbody> </tbody>
<tfoot> <tfoot>
<tr class="cctr-total-row"> <tr class="cctr-total-row">
<td colspan="5">Total Recovered</td> <td colspan="7">Total (Target once per account · Actual across all entries shown)</td>
<td class="num">₹{{ totalRecoveredAmount | number: '1.0-0' }}</td> <td class="num">₹{{ recoveryTargetTotal | number: '1.0-0' }}</td>
<td class="num">₹{{ recoveryActualTotal | number: '1.0-0' }}</td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
@@ -459,9 +489,14 @@
@if (selectedAccount; as a) { @if (selectedAccount; as a) {
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label"> <div class="pos-section-label npa-detail-header">
{{ a.accountNo }} — {{ a.customerName }} <span
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span> >{{ a.accountNo }} — {{ a.customerName }}
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span></span
>
<button type="button" class="pg-btn npa-timeline-trigger" (click)="openTimelineModal()">
<fa-icon icon="history"></fa-icon> Recovery Journey
</button>
</div> </div>
<dl class="npa-detail-grid"> <dl class="npa-detail-grid">
<div class="npa-detail-field"> <div class="npa-detail-field">
@@ -781,7 +816,97 @@
</div> </div>
} }
<!-- ===================== 08 PROBABLE NPA ===================== --> <!-- ===================== 08 UNDEBITED INTEREST RECOVERY REPORT ===================== -->
@if (activeReport === 'undebited-interest') {
<div class="pg-card">
<div class="pos-section-label">
Undebited Interest Recovery Report — Memorandum Interest Held Outside the Loan Account (IRAC Norms)
</div>
<p class="pg-sub npa-report-note">
Once an account turns NPA, interest is no longer debited to the borrower's loan account or recognised as income
— it is tracked here as memorandum "undebited interest" and recognised only when actually recovered in cash.
Cash recovered is appropriated to this bucket first; any balance beyond it reduces principal.
</p>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Stage</th>
<th>NPA Since</th>
<th class="num">Days NPA</th>
<th class="num">Rate</th>
<th class="num col-out">Undebited Interest Accrued</th>
<th class="num col-in">Interest Recovered (Cash)</th>
<th class="num">Undebited Interest Balance</th>
</tr>
</thead>
<tbody>
@for (a of paged(npaAccounts); track a.accountNo) {
<tr>
<td>
<span class="pg-chip">{{ a.accountNo }}</span>
</td>
<td>{{ a.customerName }}</td>
<td>{{ a.branchName }}</td>
<td>
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
</td>
<td>{{ npaSince(a) }}</td>
<td class="num">{{ daysSinceNpaOf(a) }}</td>
<td class="num">{{ a.interestRate }}%</td>
<td class="num col-out">₹{{ interestRev(a) | number: '1.0-0' }}</td>
<td class="num col-in">₹{{ interestRecoveredCashOf(a) | number: '1.0-0' }}</td>
<td class="num">₹{{ undebitedInterestBalanceOf(a) | number: '1.0-0' }}</td>
</tr>
} @empty {
<tr>
<td colspan="10" class="pg-empty">No NPA accounts in this branch.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="7">Total ({{ npaAccounts.length }} accounts)</td>
<td class="num">₹{{ undebitedInterestAccruedTotal | number: '1.0-0' }}</td>
<td class="num">₹{{ interestRecoveredCashTotal | number: '1.0-0' }}</td>
<td class="num">₹{{ undebitedInterestBalanceTotal | number: '1.0-0' }}</td>
</tr>
</tfoot>
</table>
</div>
@if (npaAccounts.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(npaAccounts) }}–{{ rangeEnd(npaAccounts) }} of {{ npaAccounts.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, npaAccounts)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(npaAccounts) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(npaAccounts)"
(click)="goToPage(currentPage + 1, npaAccounts)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== 09 PROBABLE NPA ===================== -->
@if (activeReport === 'probable') { @if (activeReport === 'probable') {
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label"> <div class="pos-section-label">
@@ -868,7 +993,7 @@
</div> </div>
} }
<!-- ===================== 09 SMA REPORT ===================== --> <!-- ===================== 10 SMA REPORT ===================== -->
@if (activeReport === 'sma') { @if (activeReport === 'sma') {
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">Special Mention Accounts — SMA-0 (1–30d) / SMA-1 (31–60d) / SMA-2 (61–90d)</div> <div class="pos-section-label">Special Mention Accounts — SMA-0 (1–30d) / SMA-1 (31–60d) / SMA-2 (61–90d)</div>
@@ -944,6 +1069,548 @@
} }
</div> </div>
} }
<!-- ===================== 11 RECOVERY MANAGEMENT DASHBOARD ===================== -->
@if (activeReport === 'recovery-dashboard') {
<div class="pg-card">
<div class="pos-section-label">Recovery Management Dashboard — Loan-Type Overdue Check</div>
<p class="pg-sub npa-report-note">
Every loan is checked for "is this cycle's dues fully recovered" the way it's actually due — a Term Loan (EMI)
is checked against its instalment, an Overdrawn (CC/OD/KCC) account against its drawing power and interest
servicing, and a Bullet loan (Gold) is checked once past maturity.
</p>
<div class="inv-kpis npa-kpis--three">
@for (row of recoveryTypeSummary; track row.type) {
<div class="inv-kpi">
<span class="inv-kpi__label">{{ row.label }}</span>
<span class="inv-kpi__value">{{ row.accounts }} accounts</span>
<span class="inv-kpi__sub" [class.npa-warn]="row.overdueCount > 0"
>{{ row.overdueCount }} overdue this cycle</span
>
</div>
}
</div>
</div>
<div class="pg-card">
<div class="pos-section-label">Expected vs Actual Recovery — This Cycle, by Loan Type</div>
<table class="pg-table">
<thead>
<tr>
<th>Loan Type</th>
<th class="num">Accounts</th>
<th class="num">Overdue Now</th>
<th class="num">Expected Recovery</th>
<th class="num col-in">Actual Recovery</th>
<th class="num">Variance</th>
</tr>
</thead>
<tbody>
@for (row of recoveryTypeSummary; track row.type) {
<tr>
<td>{{ row.label }}</td>
<td class="num">{{ row.accounts }}</td>
<td class="num">{{ row.overdueCount }}</td>
<td class="num">₹{{ row.expected | number: '1.0-0' }}</td>
<td class="num col-in">₹{{ row.actual | number: '1.0-0' }}</td>
<td class="num" [class.col-out]="row.actual - row.expected < 0">
₹{{ row.actual - row.expected | number: '1.0-0' }}
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td>Total</td>
<td class="num">{{ allAccounts.length }}</td>
<td class="num">
{{
recoveryTypeSummary[0].overdueCount +
recoveryTypeSummary[1].overdueCount +
recoveryTypeSummary[2].overdueCount
}}
</td>
<td class="num">₹{{ expectedRecoveryTotal | number: '1.0-0' }}</td>
<td class="num">₹{{ actualRecoveryTotal | number: '1.0-0' }}</td>
<td class="num" [class.col-out]="recoveryVarianceTotal < 0">
₹{{ recoveryVarianceTotal | number: '1.0-0' }}
</td>
</tr>
</tfoot>
</table>
<p class="pg-sub npa-report-note">
Overall recovery rate: {{ recoveryPctTotal | number: '1.1-1' }}% of this cycle's expected dues.
</p>
</div>
<div class="pg-card">
<div class="pos-section-label">Loan-wise Expected vs Actual Recovery</div>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Loan Type</th>
<th class="num">Expected</th>
<th class="num col-in">Actual</th>
<th class="num">Variance</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (a of paged(loanWiseRecoveryAccounts); track a.accountNo) {
<tr>
<td>
<span class="pg-chip">{{ a.accountNo }}</span>
</td>
<td>{{ a.customerName }}</td>
<td>{{ a.branchName }}</td>
<td>{{ repaymentTypeLabel(a.repaymentType) }}</td>
<td class="num">₹{{ expectedThisMonth(a) | number: '1.0-0' }}</td>
<td class="num col-in">₹{{ actualThisMonth(a) | number: '1.0-0' }}</td>
<td class="num" [class.col-out]="recoveryVariance(a) < 0">
₹{{ recoveryVariance(a) | number: '1.0-0' }}
</td>
<td>
<span
class="npa-badge"
[class.npa-badge--standard]="recoveryVariance(a) >= 0"
[class.npa-badge--substandard]="recoveryVariance(a) < 0"
>
{{ recoveryVariance(a) >= 0 ? 'Recovered' : 'Shortfall' }}
</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="pg-empty">No accounts in this branch.</td>
</tr>
}
</tbody>
</table>
</div>
@if (loanWiseRecoveryAccounts.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(loanWiseRecoveryAccounts) }}–{{ rangeEnd(loanWiseRecoveryAccounts) }} of
{{ loanWiseRecoveryAccounts.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, loanWiseRecoveryAccounts)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(loanWiseRecoveryAccounts) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(loanWiseRecoveryAccounts)"
(click)="goToPage(currentPage + 1, loanWiseRecoveryAccounts)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== 12 DAY-WISE OVERDUE REPORT ===================== -->
@if (activeReport === 'daywise-overdue') {
<div class="pg-card">
<div class="pos-section-label">Day-wise Overdue Report — Ageing Buckets</div>
<table class="pg-table">
<thead>
<tr>
<th>Overdue Bucket</th>
<th class="num">Accounts</th>
<th class="num">Outstanding</th>
<th class="num col-out">Overdue Amount</th>
</tr>
</thead>
<tbody>
@for (b of overdueAgeingBuckets; track b.label) {
<tr>
<td>{{ b.label }}</td>
<td class="num">{{ b.count }}</td>
<td class="num">₹{{ b.outstanding | number: '1.0-0' }}</td>
<td class="num col-out">₹{{ b.overdueAmt | number: '1.0-0' }}</td>
</tr>
}
</tbody>
</table>
</div>
<div class="pg-card">
<div class="pos-section-label">Overdue Accounts — Day-wise, Marked by SMA Stage</div>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Product</th>
<th>Stage</th>
<th class="num">Overdue Days</th>
<th class="num">Outstanding</th>
<th class="num col-out">Overdue Amount</th>
</tr>
</thead>
<tbody>
@for (a of paged(overdueDaysAccounts); track a.accountNo) {
<tr>
<td>
<span class="pg-chip">{{ a.accountNo }}</span>
</td>
<td>{{ a.customerName }}</td>
<td>{{ a.branchName }}</td>
<td>{{ a.productName }}</td>
<td>
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
</td>
<td class="num">{{ a.overdueDays }}</td>
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
<td class="num col-out">₹{{ a.overdueAmount | number: '1.0-0' }}</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="pg-empty">No overdue accounts in this branch.</td>
</tr>
}
</tbody>
</table>
</div>
@if (overdueDaysAccounts.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(overdueDaysAccounts) }}–{{ rangeEnd(overdueDaysAccounts) }} of
{{ overdueDaysAccounts.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, overdueDaysAccounts)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(overdueDaysAccounts) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(overdueDaysAccounts)"
(click)="goToPage(currentPage + 1, overdueDaysAccounts)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== 13 DAY-WISE OVERDRAWN REPORT ===================== -->
@if (activeReport === 'daywise-overdrawn') {
<div class="pg-card">
<div class="pos-section-label">
Day-wise Overdrawn Report — Cash Credit / Overdraft / KCC Accounts ({{ overdrawnBreachCount }} of
{{ overdrawnAccounts.length }} breaching Drawing Power)
</div>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Product</th>
<th class="num">Drawing Power</th>
<th class="num">Outstanding</th>
<th class="num col-out">Excess Over DP</th>
<th class="num">Days Overdrawn</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (a of paged(overdrawnAccounts); track a.accountNo) {
<tr>
<td>
<span class="pg-chip">{{ a.accountNo }}</span>
</td>
<td>{{ a.customerName }}</td>
<td>{{ a.branchName }}</td>
<td>{{ a.productName }}</td>
<td class="num">₹{{ a.drawingPower | number: '1.0-0' }}</td>
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
<td class="num col-out">₹{{ excessOverDp(a) | number: '1.0-0' }}</td>
<td class="num">{{ overdrawnBreachOf(a) ? a.overdueDays : 0 }}</td>
<td>
<span
class="npa-badge"
[class.npa-badge--standard]="!overdrawnBreachOf(a)"
[class.npa-badge--substandard]="overdrawnBreachOf(a)"
>
{{ overdrawnBreachOf(a) ? 'DP Breached' : 'Within Limit' }}
</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="pg-empty">No Overdrawn accounts in this branch.</td>
</tr>
}
</tbody>
</table>
</div>
@if (overdrawnAccounts.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(overdrawnAccounts) }}–{{ rangeEnd(overdrawnAccounts) }} of
{{ overdrawnAccounts.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, overdrawnAccounts)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(overdrawnAccounts) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(overdrawnAccounts)"
(click)="goToPage(currentPage + 1, overdrawnAccounts)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== 14 AGRICULTURE / GOLD MARKET VALUE REPORT ===================== -->
@if (activeReport === 'agri-gold-value') {
<div class="pg-card">
<div class="pos-section-label">Agriculture / Gold Market Value Report — Current Security Value vs Loan</div>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Product</th>
<th class="num">Outstanding</th>
<th class="num">Collateral Value (At Sanction)</th>
<th class="num">Market Value (Now)</th>
<th class="num">Coverage %</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (a of paged(agriGoldAccounts); track a.accountNo) {
<tr>
<td>
<span class="pg-chip">{{ a.accountNo }}</span>
</td>
<td>{{ a.customerName }}</td>
<td>{{ a.branchName }}</td>
<td>{{ a.productName }}</td>
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
<td class="num">₹{{ a.collateralValue | number: '1.0-0' }}</td>
<td
class="num"
[class.col-in]="a.collateralMarketValue >= a.collateralValue"
[class.col-out]="a.collateralMarketValue < a.collateralValue"
>
₹{{ a.collateralMarketValue | number: '1.0-0' }}
</td>
<td class="num">{{ coverageRatio(a) | number: '1.0-0' }}%</td>
<td>
<span
class="npa-badge"
[class.npa-badge--standard]="!undersecuredOf(a)"
[class.npa-badge--substandard]="undersecuredOf(a)"
>
{{ undersecuredOf(a) ? 'Undersecured' : 'Adequate' }}
</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="pg-empty">No Agriculture / Gold accounts in this branch.</td>
</tr>
}
</tbody>
</table>
</div>
@if (agriGoldAccounts.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(agriGoldAccounts) }}–{{ rangeEnd(agriGoldAccounts) }} of
{{ agriGoldAccounts.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, agriGoldAccounts)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(agriGoldAccounts) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(agriGoldAccounts)"
(click)="goToPage(currentPage + 1, agriGoldAccounts)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== 15 EXCEPTION CHECK REPORT ===================== -->
@if (activeReport === 'exception-check') {
<div class="pg-card">
<div class="pos-section-label">
Exception Check — Security Value vs Loan ({{ exceptionAccounts.length }} accounts undersecured)
</div>
<p class="pg-sub npa-report-note">
Every secured loan's current security market value is expected to stay above its outstanding balance. This lists
the exceptions — where it doesn't — so recovery/credit can review before the next classification review.
</p>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Product</th>
<th>Stage</th>
<th class="num">Outstanding</th>
<th class="num">Market Value</th>
<th class="num col-out">Shortfall</th>
<th class="num">Coverage %</th>
</tr>
</thead>
<tbody>
@for (a of paged(exceptionAccounts); track a.accountNo) {
<tr>
<td>
<span class="pg-chip">{{ a.accountNo }}</span>
</td>
<td>{{ a.customerName }}</td>
<td>{{ a.branchName }}</td>
<td>{{ a.productName }}</td>
<td>
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
</td>
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
<td class="num">₹{{ a.collateralMarketValue | number: '1.0-0' }}</td>
<td class="num col-out">₹{{ securityShortfall(a) | number: '1.0-0' }}</td>
<td class="num">{{ coverageRatio(a) | number: '1.0-0' }}%</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="pg-empty">No undersecured accounts in this branch.</td>
</tr>
}
</tbody>
</table>
</div>
@if (exceptionAccounts.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(exceptionAccounts) }}–{{ rangeEnd(exceptionAccounts) }} of
{{ exceptionAccounts.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, exceptionAccounts)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(exceptionAccounts) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(exceptionAccounts)"
(click)="goToPage(currentPage + 1, exceptionAccounts)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
</div> </div>
<!-- ===================== Recovery / Collection Journey popup ===================== -->
@if (showTimelineModal && selectedAccount; as a) {
<div class="npa-timeline-backdrop" (click)="closeTimelineModal()">
<div class="npa-timeline-modal" (click)="$event.stopPropagation()">
<header class="npa-timeline-modal__head">
<div class="npa-timeline-modal__title">
<span class="npa-timeline-modal__eyebrow">Recovery / Collection Journey</span>
<h2>{{ a.accountNo }} — {{ a.customerName }}</h2>
<div class="npa-timeline-modal__meta">
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
<span class="npa-timeline-modal__branch">{{ a.branchName }}</span>
<span class="npa-timeline-modal__count">{{ a.collectionTimeline.length }} touchpoints</span>
</div>
</div>
<button type="button" class="npa-timeline-modal__close" (click)="closeTimelineModal()" aria-label="Close">
<fa-icon icon="times"></fa-icon>
</button>
</header>
<div class="npa-timeline-modal__body">
@if (a.collectionTimeline.length) {
<ol class="npa-timeline">
@for (e of a.collectionTimeline; track e.date + e.type) {
<li [class]="'npa-timeline-item npa-timeline-item--' + timelineEventTone(e.type)">
<span class="npa-timeline-item__icon"><fa-icon [icon]="timelineEventIcon(e.type)"></fa-icon></span>
<div class="npa-timeline-item__body">
<div class="npa-timeline-item__row">
<span class="npa-timeline-item__type">{{ e.type }}</span>
<span class="npa-timeline-item__date">{{ e.date }}</span>
</div>
<p class="npa-timeline-item__remarks">{{ e.remarks }}</p>
<span class="npa-timeline-item__channel">{{ e.channel }}</span>
</div>
</li>
}
</ol>
} @else {
<div class="npa-timeline-empty">
<fa-icon icon="check-circle"></fa-icon>
<p>No collection activity — this account has never gone overdue.</p>
</div>
}
</div>
</div>
</div>
}
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div> <div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
File diff suppressed because it is too large Load Diff
+299
View File
@@ -10,6 +10,10 @@
// and ../investment/investment-shared.scss (inv-kpis, inv-form-*, inv-toast — reused as-is since // 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). // they are the same generic "KPI tile / entry form / toast" primitives, not Investment-specific).
.npa-report-note {
max-width: none;
}
.npa-badge { .npa-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -84,6 +88,301 @@
} }
} }
// 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 { .npa-detail-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
@@ -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-header>
<mat-card-content class="aml-card-content"> <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"> <table mat-table [dataSource]="rules" class="aml-table mat-elevation-z1">
<!-- Order Position Column --> <!-- Order Position Column -->
<ng-container matColumnDef="orderPosition"> <ng-container matColumnDef="orderPosition">
@@ -45,10 +61,37 @@
</td> </td>
</ng-container> </ng-container>
<!-- Income / Occupation Limit Column --> <!-- What the min/max figures are measured against -->
<ng-container matColumnDef="incomeLimit"> <ng-container matColumnDef="limitBasis">
<th mat-header-cell *matHeaderCellDef>Income / Occupation Limit</th> <th mat-header-cell *matHeaderCellDef>Limit Basis</th>
<td mat-cell *matCellDef="let element" class="income-limit-cell">{{ getIncomeLimitLabel(element) }}</td> <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> </ng-container>
<!-- Reason Column --> <!-- Reason Column -->
@@ -114,13 +114,141 @@
max-width: 400px; max-width: 400px;
} }
.income-limit-cell { .col-right {
text-align: right;
justify-content: flex-end;
}
/* What the min/max amounts are measured against. */
.basis-cell {
color: #334155; color: #334155;
font-size: 13px; font-size: 12px;
font-weight: 500; font-weight: 600;
white-space: normal; white-space: normal;
max-width: 200px; max-width: 150px;
line-height: 1.35; 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 */ /* Badges for Risk Category */
@@ -42,24 +42,185 @@ export class AmlRulesComponent implements OnInit {
'orderPosition', 'orderPosition',
'ruleName', 'ruleName',
'riskCategory', 'riskCategory',
'incomeLimit', 'limitBasis',
'minLimit',
'maxLimit',
'appliesOver',
'reason', 'reason',
'amlCheckStatus', 'amlCheckStatus',
'bannedEntityMatch' '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; @ViewChild(MatPaginator) paginator!: MatPaginator;
ngOnInit(): void { ngOnInit(): void {
this.clientsService.getClientRiskRules().subscribe({ this.clientsService.getClientRiskRules().subscribe({
next: (data) => { next: (data) => {
this.rules.data = data || []; this.allRules = data || [];
this.applyPeriodFilter();
this.rules.paginator = this.paginator; this.rules.paginator = this.paginator;
}, },
error: () => {} 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 { getRiskCategoryLabel(category: number): string {
switch (category) { switch (category) {
case 1: case 1:
@@ -85,39 +246,4 @@ export class AmlRulesComponent implements OnInit {
return ''; return '';
} }
} }
/** Formats whichever income/turnover/occupation criterion drives this rule's risk mark. */
getIncomeLimitLabel(rule: any): string {
const inr = (n: number) => '₹' + Number(n).toLocaleString('en-IN');
if (rule.minMonthlyIncome != null && rule.minMonthlyIncome > 0) {
return rule.maxMonthlyIncome != null && rule.maxMonthlyIncome > 0
? `Monthly Income ${inr(rule.minMonthlyIncome)} - ${inr(rule.maxMonthlyIncome)}`
: `Monthly Income > ${inr(rule.minMonthlyIncome)}`;
}
if (rule.minAnnualIncome != null && rule.minAnnualIncome > 0) {
return rule.maxAnnualIncome != null && rule.maxAnnualIncome > 0
? `Annual Income ${inr(rule.minAnnualIncome)} - ${inr(rule.maxAnnualIncome)}`
: `Annual Income > ${inr(rule.minAnnualIncome)}`;
}
if (rule.minAnnualTurnover != null && rule.minAnnualTurnover > 0) {
return rule.maxAnnualTurnover != null && rule.maxAnnualTurnover > 0
? `Annual Turnover ${inr(rule.minAnnualTurnover)} - ${inr(rule.maxAnnualTurnover)}`
: `Annual Turnover > ${inr(rule.minAnnualTurnover)}`;
}
if (rule.occupationName) return `Occupation: ${rule.occupationName}`;
if (rule.occupationCategoryName) return `Occupation Category: ${rule.occupationCategoryName}`;
if (rule.txnThresholdAmountMediumRisk != null || rule.txnThresholdAmountHighRisk != null) {
const parts: string[] = [];
if (rule.txnThresholdAmountMediumRisk != null) parts.push(`${inr(rule.txnThresholdAmountMediumRisk)} (Medium Risk)`);
if (rule.txnThresholdAmountHighRisk != null) parts.push(`${inr(rule.txnThresholdAmountHighRisk)} (High Risk)`);
return `Txn Amount ≥ ${parts.join(' / ')}`;
}
if (rule.txnThresholdAmount != null) {
const amt = inr(rule.txnThresholdAmount);
if (rule.txnRuleType === 'THRESHOLD') return `Txn Amount ≥ ${amt}`;
if (rule.txnWindowDays != null) return `${amt} within ${rule.txnWindowDays} days`;
return `Txn Amount ≥ ${amt}`;
}
return 'Behavioral / Transaction Pattern';
}
} }
@@ -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;
}
+152
View File
@@ -360,6 +360,158 @@ td.derived {
color: var(--o-g500); 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). */ /* Tie-up status pills (cross-return reconciliation checks). */
.report-pill { .report-pill {
display: inline-block; display: inline-block;
+36 -25
View File
@@ -7,13 +7,7 @@
*/ */
import * as ExcelJS from 'exceljs'; import * as ExcelJS from 'exceljs';
import { import { MatrixTable, ReturnGeneralInfoRow, ReturnLine, ReturnSignatory, SIGNATORY_FIELDS } from './oss-return.model';
MatrixTable,
ReturnGeneralInfoRow,
ReturnLine,
ReturnSignatory,
SIGNATORY_FIELDS
} from './oss-return.model';
/** Palette lifted from the RBI CIMS workbooks so exports look like the originals. */ /** Palette lifted from the RBI CIMS workbooks so exports look like the originals. */
export const XL = { export const XL = {
@@ -45,12 +39,7 @@ export function addBanner(sheet: ExcelJS.Worksheet, title: string, lastCol: stri
sheet.addRow([]); sheet.addRow([]);
} }
export function boxAllCells( export function boxAllCells(sheet: ExcelJS.Worksheet, from: number, to: number, lastColIndex: number): void {
sheet: ExcelJS.Worksheet,
from: number,
to: number,
lastColIndex: number
): void {
for (let r = from; r <= to; r++) { for (let r = from; r <= to; r++) {
for (let c = 1; c <= lastColIndex; c++) { for (let c = 1; c <= lastColIndex; c++) {
sheet.getRow(r).getCell(c).border = { sheet.getRow(r).getCell(c).border = {
@@ -64,7 +53,10 @@ export function boxAllCells(
} }
export function addSectionHeader(sheet: ExcelJS.Worksheet, label: string, unitLabel?: string): void { export function addSectionHeader(sheet: ExcelJS.Worksheet, label: string, unitLabel?: string): void {
const row = sheet.addRow([label, unitLabel ?? '']); const row = sheet.addRow([
label,
unitLabel ?? ''
]);
row.font = { bold: true }; row.font = { bold: true };
row.eachCell((cell) => (cell.fill = solidFill(XL.headerGrey))); row.eachCell((cell) => (cell.fill = solidFill(XL.headerGrey)));
row.getCell(2).alignment = { horizontal: 'right' }; row.getCell(2).alignment = { horizontal: 'right' };
@@ -73,7 +65,10 @@ export function addSectionHeader(sheet: ExcelJS.Worksheet, label: string, unitLa
/** Renders a labelled block; indents by level and shades derived totals. */ /** Renders a labelled block; indents by level and shades derived totals. */
export function addLineRows(sheet: ExcelJS.Worksheet, lines: ReturnLine[]): void { export function addLineRows(sheet: ExcelJS.Worksheet, lines: ReturnLine[]): void {
for (const line of lines) { for (const line of lines) {
const row = sheet.addRow([line.label, line.value]); const row = sheet.addRow([
line.label,
line.value
]);
const labelCell = row.getCell(1); const labelCell = row.getCell(1);
const valueCell = row.getCell(2); const valueCell = row.getCell(2);
@@ -98,7 +93,10 @@ export function addMatrixTable(sheet: ExcelJS.Worksheet, table: MatrixTable): vo
const colCount = table.columns.length + 1; const colCount = table.columns.length + 1;
const start = sheet.rowCount + 1; const start = sheet.rowCount + 1;
const titleRow = sheet.addRow([table.title, ...(table.unit ? [table.unit] : [])]); const titleRow = sheet.addRow([
table.title,
...(table.unit ? [table.unit] : [])
]);
if (!table.unit) { if (!table.unit) {
sheet.mergeCells(titleRow.number, 1, titleRow.number, colCount); sheet.mergeCells(titleRow.number, 1, titleRow.number, colCount);
} }
@@ -115,13 +113,19 @@ export function addMatrixTable(sheet: ExcelJS.Worksheet, table: MatrixTable): vo
sub.getCell(1).fill = solidFill(XL.headerGrey); sub.getCell(1).fill = solidFill(XL.headerGrey);
} }
const head = sheet.addRow(['Particulars', ...table.columns]); const head = sheet.addRow([
'Particulars',
...table.columns
]);
head.font = { bold: true }; head.font = { bold: true };
head.alignment = { wrapText: true, horizontal: 'center', vertical: 'middle' }; head.alignment = { wrapText: true, horizontal: 'center', vertical: 'middle' };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const row of table.rows) { for (const row of table.rows) {
const excelRow = sheet.addRow([row.label, ...row.values]); const excelRow = sheet.addRow([
row.label,
...row.values
]);
const labelCell = excelRow.getCell(1); const labelCell = excelRow.getCell(1);
labelCell.alignment = { wrapText: true, vertical: 'middle', indent: row.level }; labelCell.alignment = { wrapText: true, vertical: 'middle', indent: row.level };
if (row.level === 0 || row.header) { if (row.level === 0 || row.header) {
@@ -158,13 +162,15 @@ export function buildGeneralInfoSheet(wb: ExcelJS.Workbook, rows: ReturnGeneralI
const sheet = wb.addWorksheet('General Information'); const sheet = wb.addWorksheet('General Information');
sheet.columns = [ sheet.columns = [
{ width: 26 }, { width: 26 },
{ width: 58 } { width: 58 }];
];
addBanner(sheet, 'General Information', 'B'); addBanner(sheet, 'General Information', 'B');
const first = sheet.rowCount + 1; const first = sheet.rowCount + 1;
for (const item of rows) { for (const item of rows) {
const row = sheet.addRow([item.label, item.value]); const row = sheet.addRow([
item.label,
item.value
]);
row.getCell(1).fill = solidFill(XL.keyCell); row.getCell(1).fill = solidFill(XL.keyCell);
// The workbook tints the audit flag yellow and leaves unfilled cells grey. // 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; const fill = item.label === 'Audit Flag' ? XL.auditYellow : item.value ? XL.derivedBlue : XL.headerGrey;
@@ -182,19 +188,24 @@ export function buildSignatorySheet(
const sheet = wb.addWorksheet(sheetName); const sheet = wb.addWorksheet(sheetName);
sheet.columns = [ sheet.columns = [
{ width: 44 }, { width: 44 },
{ width: 24 } { width: 24 }];
];
addBanner(sheet, sheetName, 'B'); addBanner(sheet, sheetName, 'B');
for (const person of signatories) { for (const person of signatories) {
const start = sheet.rowCount + 1; const start = sheet.rowCount + 1;
const head = sheet.addRow([person.heading, '']); const head = sheet.addRow([
person.heading,
''
]);
head.font = { bold: true }; head.font = { bold: true };
head.alignment = { horizontal: 'center' }; head.alignment = { horizontal: 'center' };
head.getCell(1).fill = solidFill(XL.keyCell); head.getCell(1).fill = solidFill(XL.keyCell);
for (const f of SIGNATORY_FIELDS) { for (const f of SIGNATORY_FIELDS) {
const row = sheet.addRow([person[f.key], f.caption]); const row = sheet.addRow([
person[f.key],
f.caption
]);
row.getCell(2).font = { bold: true }; row.getCell(2).font = { bold: true };
row.getCell(2).alignment = { horizontal: 'center' }; row.getCell(2).alignment = { horizontal: 'center' };
row.getCell(2).fill = solidFill(XL.keyCell); row.getCell(2).fill = solidFill(XL.keyCell);
+100 -20
View File
@@ -153,8 +153,7 @@ export const RAL_LIABILITIES: RalLine[] = [
{ label: 'ii. Borrowings', level: 1, value: null }, { label: 'ii. Borrowings', level: 1, value: null },
{ {
label: label: '12. Risk Provisions Other than netted off assets (BDDR, Spl BDDR, Depreciation on fixed assets etc.,)',
'12. Risk Provisions Other than netted off assets (BDDR, Spl BDDR, Depreciation on fixed assets etc.,)',
level: 0, level: 0,
value: 39440, value: 39440,
total: true total: true
@@ -192,7 +191,12 @@ export const RAL_ASSETS: RalLine[] = [
{ label: '2. I. Due from Banks', level: 0, value: 57600, total: true }, { label: '2. I. Due from Banks', level: 0, value: 57600, total: true },
{ label: 'A. Balances with Banks', level: 1, 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: '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: 'a. Current', level: 2, value: 5980 },
{ label: 'b. Savings', level: 2, value: null }, { label: 'b. Savings', level: 2, value: null },
{ label: 'c. Fixed', level: 2, value: 26288 }, { label: 'c. Fixed', level: 2, value: 26288 },
@@ -353,26 +357,73 @@ export const RAL_AGEING_HEADERS: string[] = [
/** Sheet 3 — Annexure 1 :: 3. Age-wise Inter Branch Adjustment Account */ /** Sheet 3 — Annexure 1 :: 3. Age-wise Inter Branch Adjustment Account */
export const RAL_INTER_BRANCH_AGEING: RalAgeingRow[] = [ 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: '3 months to 6 months',
{ period: 'Above 1 year to 2 years', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null }, noOfDebitEntries: null,
{ period: 'Above 2 years', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: 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 } { period: 'Total', noOfDebitEntries: 0, amtOfDebitEntries: 0, noOfCreditEntries: 0, amtOfCreditEntries: 0 }
]; ];
/** Sheet 3 — Annexure 1 :: 4. Age-wise Inter Bank Reconciliation Account */ /** Sheet 3 — Annexure 1 :: 4. Age-wise Inter Bank Reconciliation Account */
export const RAL_INTER_BANK_AGEING: RalAgeingRow[] = [ 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: '3 months to 6 months',
{ period: 'Above 1 year to 2 years', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null }, noOfDebitEntries: null,
{ period: 'Above 2 years', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: 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 } { period: 'Total', noOfDebitEntries: 0, amtOfDebitEntries: 0, noOfCreditEntries: 0, amtOfCreditEntries: 0 }
]; ];
/** Sheet 3 — Annexure 1 :: 5. Unsecured guarantees issued by the bank */ /** Sheet 3 — Annexure 1 :: 5. Unsecured guarantees issued by the bank */
export const RAL_UNSECURED_GUARANTEES: RalUnsecuredGuarantee[] = [ export const RAL_UNSECURED_GUARANTEES: RalUnsecuredGuarantee[] = [
{ srNo: 1, borrower: '', beneficiary: '', amountOutstanding: null } { srNo: 1, borrower: '', beneficiary: '', amountOutstanding: null }];
];
export const RAL_UNSECURED_GUARANTEES_TOTAL = 0; export const RAL_UNSECURED_GUARANTEES_TOTAL = 0;
/** Sheet 4 — Annexure 2 :: Sundry Debtors ageing buckets */ /** Sheet 4 — Annexure 2 :: Sundry Debtors ageing buckets */
@@ -401,20 +452,49 @@ export const RAL_SUNDRY_DEBTORS_NOTE =
export const RAL_DEPOSITORS_BORROWERS: RalLine[] = [ export const RAL_DEPOSITORS_BORROWERS: RalLine[] = [
{ label: '1.Total No of Depositors', level: 0, value: 8156, total: true, count: true }, { 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: '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: '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: '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: '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: '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: '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: '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: '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: '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: '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: '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: '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: '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: '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 } { label: 'ii) Of which total Loans and Advances Amount', level: 1, value: 21260 }
]; ];
@@ -81,11 +81,16 @@
<div class="oss-section"> <div class="oss-section">
<div class="oss-sheet-banner">Assets &amp; Liabilities</div> <div class="oss-sheet-banner">Assets &amp; Liabilities</div>
<ng-container [ngTemplateOutlet]="glBar"></ng-container>
<div class="oss-tbl-wrap"> <div class="oss-tbl-wrap">
<table class="oss-tbl"> <table class="oss-tbl">
<thead> <thead>
<tr> <tr>
<th>Liabilities</th> <th>Liabilities</th>
@if (glMapMode) {
<th class="oss-gl-col">Mapped GL accounts</th>
}
<th class="ta-r">Rs in Thousands</th> <th class="ta-r">Rs in Thousands</th>
</tr> </tr>
</thead> </thead>
@@ -93,8 +98,25 @@
@for (line of liabilities; track $index) { @for (line of liabilities; track $index) {
<tr [class.total-row]="line.total"> <tr [class.total-row]="line.total">
<td [class]="'lvl-' + line.level">{{ line.label }}</td> <td [class]="'lvl-' + line.level">{{ line.label }}</td>
<td class="ta-r mono" [class.derived]="line.total"> @if (glMapMode) {
{{ line.value === null ? '' : (line.value | number: line.count ? '1.0-0' : '1.2-2') }} <td>
<ng-container
[ngTemplateOutlet]="glCell"
[ngTemplateOutletContext]="{ label: line.label, filed: line.value }"
></ng-container>
</td>
}
<td
class="ta-r mono"
[class.derived]="line.total"
[class.gl-sourced]="isGlMapped(line.label)"
[class.gl-diff]="glDiffers(line.label, line.value)"
>
{{
effectiveValue(line.label, line.value) === null
? ''
: (effectiveValue(line.label, line.value) | number: (line.count ? '1.0-0' : '1.2-2'))
}}
</td> </td>
</tr> </tr>
} }
@@ -107,6 +129,9 @@
<thead> <thead>
<tr> <tr>
<th>Assets</th> <th>Assets</th>
@if (glMapMode) {
<th class="oss-gl-col">Mapped GL accounts</th>
}
<th class="ta-r">Rs in Thousands</th> <th class="ta-r">Rs in Thousands</th>
</tr> </tr>
</thead> </thead>
@@ -114,8 +139,25 @@
@for (line of assets; track $index) { @for (line of assets; track $index) {
<tr [class.total-row]="line.total"> <tr [class.total-row]="line.total">
<td [class]="'lvl-' + line.level">{{ line.label }}</td> <td [class]="'lvl-' + line.level">{{ line.label }}</td>
<td class="ta-r mono" [class.derived]="line.total"> @if (glMapMode) {
{{ line.value === null ? '' : (line.value | number: line.count ? '1.0-0' : '1.2-2') }} <td>
<ng-container
[ngTemplateOutlet]="glCell"
[ngTemplateOutletContext]="{ label: line.label, filed: line.value }"
></ng-container>
</td>
}
<td
class="ta-r mono"
[class.derived]="line.total"
[class.gl-sourced]="isGlMapped(line.label)"
[class.gl-diff]="glDiffers(line.label, line.value)"
>
{{
effectiveValue(line.label, line.value) === null
? ''
: (effectiveValue(line.label, line.value) | number: (line.count ? '1.0-0' : '1.2-2'))
}}
</td> </td>
</tr> </tr>
} }
@@ -343,7 +385,7 @@
<tr [class.total-row]="line.total"> <tr [class.total-row]="line.total">
<td [class]="'lvl-' + line.level">{{ line.label }}</td> <td [class]="'lvl-' + line.level">{{ line.label }}</td>
<td class="ta-r mono" [class.derived]="line.total"> <td class="ta-r mono" [class.derived]="line.total">
{{ line.value === null ? '' : (line.value | number: line.count ? '1.0-0' : '1.2-2') }} {{ line.value === null ? '' : (line.value | number: (line.count ? '1.0-0' : '1.2-2')) }}
</td> </td>
</tr> </tr>
} }
@@ -417,6 +459,68 @@
</div> </div>
} }
<!-- GL mapping toolbar, shown above any mappable statement -->
<ng-template #glBar>
<div class="oss-gl-bar" [class.on]="glMapMode">
<div class="oss-gl-bar-l">
<fa-icon icon="project-diagram"></fa-icon>
<span>
<strong>General Ledger mapping</strong>
@if (glMappedCount) {
<span class="oss-gl-count">{{ glMappedCount }} line(s) mapped</span>
} @else {
<span class="muted"> — pin GL accounts to a line and its figure comes from the ledger</span>
}
</span>
</div>
<div class="oss-gl-bar-r">
@if (glMapMode) {
@if (canSelectGlBranch) {
<select class="oss-gl-inp" [(ngModel)]="glBranchCode">
<option value="">All branches</option>
@for (b of glBranches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
}
</select>
} @else {
<span class="oss-gl-scope" title="Your login is restricted to this branch">
<fa-icon icon="university"></fa-icon> {{ glScopeName }}
</span>
}
@if (glMappedCount) {
<button class="oss-btn oss-btn-clear" (click)="clearAllGlMappings()">Clear all</button>
}
}
<button class="oss-btn" [class.oss-btn-xls]="!glMapMode" (click)="toggleGlMapMode()">
<fa-icon [icon]="glMapMode ? 'times' : 'plus'"></fa-icon>
{{ glMapMode ? 'Done' : 'Add / Remove GLs' }}
</button>
</div>
</div>
</ng-template>
<!-- Per-line GL chips and picker trigger -->
<ng-template #glCell let-label="label" let-filed="filed">
<div class="oss-gl-cell">
@for (code of glCodesFor(label); track code) {
<span class="oss-gl-chip">
<span class="mono">{{ code }}</span>
<button class="oss-gl-chip-x" (click)="removeGlFromLine(label, code)" [attr.aria-label]="'Remove ' + code">
<fa-icon icon="times"></fa-icon>
</button>
</span>
}
<button class="oss-gl-add" (click)="openGlPicker(label, filed)">
<fa-icon icon="plus"></fa-icon> {{ glCodesFor(label).length ? 'Edit' : 'Add GL' }}
</button>
@if (glDiffers(label, filed)) {
<span class="oss-gl-warn" title="Ledger figure differs from the value filed on the return">
<fa-icon icon="exclamation-triangle"></fa-icon> differs from filed
</span>
}
</div>
</ng-template>
<!-- TOAST --> <!-- TOAST -->
<div class="oss-toast" [class.show]="toastVisible">{{ toastMsg }}</div> <div class="oss-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
</div> </div>
@@ -11,6 +11,7 @@ import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import * as ExcelJS from 'exceljs'; import * as ExcelJS from 'exceljs';
import { GlMappingHost } from '../gl-mapping.host';
import { import {
addBanner, addBanner,
addLineRows, addLineRows,
@@ -55,7 +56,14 @@ type Oss1Tab = 'general' | 'balance-sheet' | 'annexure1' | 'annexure2' | 'signat
FormsModule FormsModule
] ]
}) })
export class Oss1RalComponent { export class Oss1RalComponent extends GlMappingHost {
readonly glReturnCode = 'R070';
/** RAL covers the reporting quarter; mapped figures are drawn from the same span. */
get glRange() {
return { from: '2026-04-01', to: '2026-06-30' };
}
activeTab: Oss1Tab = 'general'; activeTab: Oss1Tab = 'general';
generalInfo = RAL_GENERAL_INFO; generalInfo = RAL_GENERAL_INFO;
@@ -146,8 +154,7 @@ export class Oss1RalComponent {
const sheet = wb.addWorksheet('Assets & Liabilities'); const sheet = wb.addWorksheet('Assets & Liabilities');
sheet.columns = [ sheet.columns = [
{ width: 78 }, { width: 78 },
{ width: 20 } { width: 20 }];
];
addBanner(sheet, 'Assets & Liabilities', 'B'); addBanner(sheet, 'Assets & Liabilities', 'B');
const first = sheet.rowCount + 1; const first = sheet.rowCount + 1;
@@ -187,18 +194,25 @@ export class Oss1RalComponent {
{ width: 16 }, { width: 16 },
{ width: 12 }, { width: 12 },
{ width: 12 }, { width: 12 },
{ width: 12 } { width: 12 }];
];
addBanner(sheet, 'Annexure 1', 'H'); addBanner(sheet, 'Annexure 1', 'H');
// 1. Break-up of Other Funds and Reserves // 1. Break-up of Other Funds and Reserves
let start = sheet.rowCount + 1; let start = sheet.rowCount + 1;
const fundsTitle = sheet.addRow(['1. Break-up of Other Funds and Reserves', '', 'Rs in Thousands']); const fundsTitle = sheet.addRow([
'1. Break-up of Other Funds and Reserves',
'',
'Rs in Thousands'
]);
sheet.mergeCells(`A${fundsTitle.number}:B${fundsTitle.number}`); sheet.mergeCells(`A${fundsTitle.number}:B${fundsTitle.number}`);
fundsTitle.font = { bold: true }; fundsTitle.font = { bold: true };
fundsTitle.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); fundsTitle.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const line of this.otherFunds) { for (const line of this.otherFunds) {
const row = sheet.addRow([line.label, '', line.value]); const row = sheet.addRow([
line.label,
'',
line.value
]);
sheet.mergeCells(`A${row.number}:B${row.number}`); sheet.mergeCells(`A${row.number}:B${row.number}`);
row.getCell(1).alignment = { wrapText: true, vertical: 'middle' }; row.getCell(1).alignment = { wrapText: true, vertical: 'middle' };
row.getCell(3).numFmt = '#,##0.00'; row.getCell(3).numFmt = '#,##0.00';
@@ -281,10 +295,20 @@ export class Oss1RalComponent {
gHead.alignment = { wrapText: true, horizontal: 'center', vertical: 'middle' }; gHead.alignment = { wrapText: true, horizontal: 'center', vertical: 'middle' };
gHead.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); gHead.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const g of this.unsecuredGuarantees) { for (const g of this.unsecuredGuarantees) {
const row = sheet.addRow([g.srNo, g.borrower, g.beneficiary, g.amountOutstanding]); const row = sheet.addRow([
g.srNo,
g.borrower,
g.beneficiary,
g.amountOutstanding
]);
row.getCell(4).numFmt = '#,##0.00'; row.getCell(4).numFmt = '#,##0.00';
} }
const gTotal = sheet.addRow(['Total', '', '', this.unsecuredGuaranteesTotal]); const gTotal = sheet.addRow([
'Total',
'',
'',
this.unsecuredGuaranteesTotal
]);
gTotal.font = { bold: true }; gTotal.font = { bold: true };
gTotal.getCell(4).numFmt = '#,##0.00'; gTotal.getCell(4).numFmt = '#,##0.00';
gTotal.getCell(4).fill = solidFill(XL.derivedBlue); gTotal.getCell(4).fill = solidFill(XL.derivedBlue);
@@ -327,9 +351,7 @@ export class Oss1RalComponent {
3, 3,
4, 4,
5 5
].forEach( ].forEach((c) => (row.getCell(c).fill = solidFill(XL.derivedBlue)));
(c) => (row.getCell(c).fill = solidFill(XL.derivedBlue))
);
} }
} }
boxAllCells(sheet, start, sheet.rowCount, 5); boxAllCells(sheet, start, sheet.rowCount, 5);
@@ -341,8 +363,7 @@ export class Oss1RalComponent {
{ width: 10 }, { width: 10 },
{ width: 62 }, { width: 62 },
{ width: 20 }, { width: 20 },
{ width: 20 } { width: 20 }];
];
addBanner(sheet, 'Annexure 2', 'D'); addBanner(sheet, 'Annexure 2', 'D');
for (const block of this.sundryDebtors) { for (const block of this.sundryDebtors) {
@@ -357,13 +378,23 @@ export class Oss1RalComponent {
s.font = { bold: true, size: 10 }; s.font = { bold: true, size: 10 };
s.getCell(1).fill = solidFill(XL.headerGrey); s.getCell(1).fill = solidFill(XL.headerGrey);
const head = sheet.addRow(['Sr. No', 'Due from Name', 'Amount due', 'Provision made']); const head = sheet.addRow([
'Sr. No',
'Due from Name',
'Amount due',
'Provision made'
]);
head.font = { bold: true }; head.font = { bold: true };
head.alignment = { horizontal: 'center' }; head.alignment = { horizontal: 'center' };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const r of block.rows) { for (const r of block.rows) {
const row = sheet.addRow([r.srNo, r.dueFromName, r.amountDue, r.provisionMade]); const row = sheet.addRow([
r.srNo,
r.dueFromName,
r.amountDue,
r.provisionMade
]);
row.getCell(3).numFmt = '#,##0.00'; row.getCell(3).numFmt = '#,##0.00';
row.getCell(4).numFmt = '#,##0.00'; row.getCell(4).numFmt = '#,##0.00';
} }
@@ -378,14 +409,22 @@ export class Oss1RalComponent {
// Details of Depositors & Borrowers // Details of Depositors & Borrowers
let start = sheet.rowCount + 1; let start = sheet.rowCount + 1;
const dbTitle = sheet.addRow(['Details of Depositors & Borrowers', '', 'Rs. In Thousands']); const dbTitle = sheet.addRow([
'Details of Depositors & Borrowers',
'',
'Rs. In Thousands'
]);
sheet.mergeCells(`A${dbTitle.number}:B${dbTitle.number}`); sheet.mergeCells(`A${dbTitle.number}:B${dbTitle.number}`);
dbTitle.font = { bold: true }; dbTitle.font = { bold: true };
dbTitle.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); dbTitle.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
dbTitle.getCell(3).alignment = { horizontal: 'right' }; dbTitle.getCell(3).alignment = { horizontal: 'right' };
for (const line of this.depositorsBorrowers) { for (const line of this.depositorsBorrowers) {
const row = sheet.addRow([line.label, '', line.value]); const row = sheet.addRow([
line.label,
'',
line.value
]);
sheet.mergeCells(`A${row.number}:B${row.number}`); sheet.mergeCells(`A${row.number}:B${row.number}`);
row.getCell(1).alignment = { wrapText: true, vertical: 'middle', indent: line.level }; row.getCell(1).alignment = { wrapText: true, vertical: 'middle', indent: line.level };
row.getCell(3).numFmt = line.count ? '#,##0' : '#,##0.00'; row.getCell(3).numFmt = line.count ? '#,##0' : '#,##0.00';
@@ -404,7 +443,11 @@ export class Oss1RalComponent {
cTitle.font = { bold: true }; cTitle.font = { bold: true };
cTitle.getCell(1).fill = solidFill(XL.headerGrey); cTitle.getCell(1).fill = solidFill(XL.headerGrey);
for (const line of this.complaints) { for (const line of this.complaints) {
const row = sheet.addRow([line.label, '', line.value]); const row = sheet.addRow([
line.label,
'',
line.value
]);
sheet.mergeCells(`A${row.number}:B${row.number}`); sheet.mergeCells(`A${row.number}:B${row.number}`);
row.getCell(1).alignment = { wrapText: true, vertical: 'middle' }; row.getCell(1).alignment = { wrapText: true, vertical: 'middle' };
row.getCell(3).numFmt = '#,##0'; row.getCell(3).numFmt = '#,##0';
@@ -122,11 +122,52 @@
</table> </table>
</div> </div>
<!-- GL mapping toolbar -->
<div class="oss-gl-bar" [class.on]="glMapMode">
<div class="oss-gl-bar-l">
<fa-icon icon="project-diagram"></fa-icon>
<span>
<strong>General Ledger mapping</strong>
@if (glMappedCount) {
<span class="oss-gl-count">{{ glMappedCount }} line(s) mapped</span>
} @else {
<span class="muted"> — pin GL accounts to a line and its figure comes from the ledger</span>
}
</span>
</div>
<div class="oss-gl-bar-r">
@if (glMapMode) {
@if (canSelectGlBranch) {
<select class="oss-gl-inp" [(ngModel)]="glBranchCode">
<option value="">All branches</option>
@for (b of glBranches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
}
</select>
} @else {
<span class="oss-gl-scope" title="Your login is restricted to this branch">
<fa-icon icon="university"></fa-icon> {{ glScopeName }}
</span>
}
@if (glMappedCount) {
<button class="oss-btn oss-btn-clear" (click)="clearAllGlMappings()">Clear all</button>
}
}
<button class="oss-btn" [class.oss-btn-xls]="!glMapMode" (click)="toggleGlMapMode()">
<fa-icon [icon]="glMapMode ? 'times' : 'plus'"></fa-icon>
{{ glMapMode ? 'Done' : 'Add / Remove GLs' }}
</button>
</div>
</div>
<div class="oss-tbl-wrap"> <div class="oss-tbl-wrap">
<table class="oss-tbl"> <table class="oss-tbl">
<thead> <thead>
<tr> <tr>
<th>Particulars</th> <th>Particulars</th>
@if (glMapMode) {
<th class="oss-gl-col">Mapped GL accounts</th>
}
<th class="ta-r">Rs in Thousand</th> <th class="ta-r">Rs in Thousand</th>
</tr> </tr>
</thead> </thead>
@@ -134,8 +175,44 @@
@for (line of earnings; track $index) { @for (line of earnings; track $index) {
<tr [class.total-row]="line.total"> <tr [class.total-row]="line.total">
<td [class]="'lvl-' + line.level">{{ line.label }}</td> <td [class]="'lvl-' + line.level">{{ line.label }}</td>
<td class="ta-r mono" [class.derived]="line.total"> @if (glMapMode) {
{{ line.value === null ? '' : (line.value | number: line.count ? '1.0-0' : '1.2-2') }} <td>
<div class="oss-gl-cell">
@for (code of glCodesFor(line.label); track code) {
<span class="oss-gl-chip">
<span class="mono">{{ code }}</span>
<button
class="oss-gl-chip-x"
(click)="removeGlFromLine(line.label, code)"
[attr.aria-label]="'Remove ' + code"
>
<fa-icon icon="times"></fa-icon>
</button>
</span>
}
<button class="oss-gl-add" (click)="openGlPicker(line.label, line.value)">
<fa-icon icon="plus"></fa-icon>
{{ glCodesFor(line.label).length ? 'Edit' : 'Add GL' }}
</button>
@if (glDiffers(line.label, line.value)) {
<span class="oss-gl-warn" title="Ledger figure differs from the value filed on the return">
<fa-icon icon="exclamation-triangle"></fa-icon> differs from filed
</span>
}
</div>
</td>
}
<td
class="ta-r mono"
[class.derived]="line.total"
[class.gl-sourced]="isGlMapped(line.label)"
[class.gl-diff]="glDiffers(line.label, line.value)"
>
{{
effectiveValue(line.label, line.value) === null
? ''
: (effectiveValue(line.label, line.value) | number: (line.count ? '1.0-0' : '1.2-2'))
}}
</td> </td>
</tr> </tr>
} }
@@ -186,9 +263,10 @@
<div class="oss-balance" [class.oss-balance--bad]="!otherExpensesTally"> <div class="oss-balance" [class.oss-balance--bad]="!otherExpensesTally">
<fa-icon [icon]="otherExpensesTally ? 'check-circle' : 'exclamation-triangle'"></fa-icon> <fa-icon [icon]="otherExpensesTally ? 'check-circle' : 'exclamation-triangle'"></fa-icon>
<span> <span>
Detail total <strong>&#8377;{{ otherExpensesTotal | number: '1.2-2' }}</strong> vs Earnings line 12 ix) Detail total <strong>&#8377;{{ otherExpensesTotal | number: '1.2-2' }}</strong> vs Earnings line 12 ix) Others
Others <strong
<strong>&#8377;{{ otherExpensesLineValue === null ? '—' : (otherExpensesLineValue | number: '1.2-2') }}</strong> >&#8377;{{ otherExpensesLineValue === null ? '—' : (otherExpensesLineValue | number: '1.2-2') }}</strong
>
— {{ otherExpensesTally ? 'tallies' : 'MISMATCH — resolve before filing' }} — {{ otherExpensesTally ? 'tallies' : 'MISMATCH — resolve before filing' }}
</span> </span>
</div> </div>
@@ -11,6 +11,7 @@ import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import * as ExcelJS from 'exceljs'; import * as ExcelJS from 'exceljs';
import { GlMappingHost } from '../gl-mapping.host';
import { import {
addBanner, addBanner,
addLineRows, addLineRows,
@@ -54,7 +55,14 @@ interface TieUp {
FormsModule FormsModule
] ]
}) })
export class Oss2RoeComponent { export class Oss2RoeComponent extends GlMappingHost {
readonly glReturnCode = 'R071';
/** RoE reports the quarter; mapped income and expense figures use the same span. */
get glRange() {
return { from: '2026-04-01', to: '2026-06-30' };
}
activeTab: Oss2Tab = 'general'; activeTab: Oss2Tab = 'general';
generalInfo = ROE_GENERAL_INFO; generalInfo = ROE_GENERAL_INFO;
@@ -178,8 +186,7 @@ export class Oss2RoeComponent {
const sheet = wb.addWorksheet('Earnings'); const sheet = wb.addWorksheet('Earnings');
sheet.columns = [ sheet.columns = [
{ width: 90 }, { width: 90 },
{ width: 20 } { width: 20 }];
];
addBanner(sheet, 'Earnings', 'B'); addBanner(sheet, 'Earnings', 'B');
const first = sheet.rowCount + 1; const first = sheet.rowCount + 1;
@@ -205,23 +212,31 @@ export class Oss2RoeComponent {
const sheet = wb.addWorksheet('Interest Receivable Not Recog'); const sheet = wb.addWorksheet('Interest Receivable Not Recog');
sheet.columns = [ sheet.columns = [
{ width: 72 }, { width: 72 },
{ width: 22 } { width: 22 }];
];
addBanner(sheet, 'Interest Receivable Not Recognized', 'B'); addBanner(sheet, 'Interest Receivable Not Recognized', 'B');
const start = sheet.rowCount + 1; const start = sheet.rowCount + 1;
const unit = sheet.addRow(['', 'Rs in Thousand']); const unit = sheet.addRow([
'',
'Rs in Thousand'
]);
unit.font = { bold: true }; unit.font = { bold: true };
unit.alignment = { horizontal: 'center' }; unit.alignment = { horizontal: 'center' };
unit.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); unit.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
const head = sheet.addRow(['Interest receivable on NPAs not recognized as income', this.quarterEndDate]); const head = sheet.addRow([
'Interest receivable on NPAs not recognized as income',
this.quarterEndDate
]);
head.font = { bold: true }; head.font = { bold: true };
head.getCell(2).alignment = { horizontal: 'center' }; head.getCell(2).alignment = { horizontal: 'center' };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const r of this.interestNotRecognized) { for (const r of this.interestNotRecognized) {
const row = sheet.addRow([r.label, r.value]); const row = sheet.addRow([
r.label,
r.value
]);
row.getCell(1).alignment = { wrapText: true, vertical: 'middle' }; row.getCell(1).alignment = { wrapText: true, vertical: 'middle' };
row.getCell(1).fill = solidFill(XL.headerGrey); row.getCell(1).fill = solidFill(XL.headerGrey);
row.getCell(2).numFmt = '#,##0.00'; row.getCell(2).numFmt = '#,##0.00';
@@ -238,28 +253,43 @@ export class Oss2RoeComponent {
sheet.columns = [ sheet.columns = [
{ width: 10 }, { width: 10 },
{ width: 56 }, { width: 56 },
{ width: 20 } { width: 20 }];
];
addBanner(sheet, 'Other Operating Expenses', 'C'); addBanner(sheet, 'Other Operating Expenses', 'C');
const start = sheet.rowCount + 1; const start = sheet.rowCount + 1;
const title = sheet.addRow(['Details of Other Operating Expenses', '', 'Rs in Thousand']); const title = sheet.addRow([
'Details of Other Operating Expenses',
'',
'Rs in Thousand'
]);
sheet.mergeCells(`A${title.number}:B${title.number}`); sheet.mergeCells(`A${title.number}:B${title.number}`);
title.font = { bold: true }; title.font = { bold: true };
title.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); title.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
title.getCell(3).alignment = { horizontal: 'center' }; title.getCell(3).alignment = { horizontal: 'center' };
const head = sheet.addRow(['Sr No', 'Particulars', 'Amount']); const head = sheet.addRow([
'Sr No',
'Particulars',
'Amount'
]);
head.font = { bold: true }; head.font = { bold: true };
head.alignment = { horizontal: 'center' }; head.alignment = { horizontal: 'center' };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const e of this.otherExpenses) { for (const e of this.otherExpenses) {
const row = sheet.addRow([e.srNo, e.particulars, e.amount]); const row = sheet.addRow([
e.srNo,
e.particulars,
e.amount
]);
row.getCell(3).numFmt = '#,##0.00'; row.getCell(3).numFmt = '#,##0.00';
} }
const total = sheet.addRow(['Total', '', this.otherExpensesTotal]); const total = sheet.addRow([
'Total',
'',
this.otherExpensesTotal
]);
sheet.mergeCells(`A${total.number}:B${total.number}`); sheet.mergeCells(`A${total.number}:B${total.number}`);
total.font = { bold: true }; total.font = { bold: true };
total.getCell(1).fill = solidFill(XL.headerGrey); total.getCell(1).fill = solidFill(XL.headerGrey);
@@ -268,7 +298,11 @@ export class Oss2RoeComponent {
boxAllCells(sheet, start, sheet.rowCount, 3); boxAllCells(sheet, start, sheet.rowCount, 3);
const ref = sheet.addRow(['', '', 'Refer Earnings Sheet — line 12 ix) Others']); const ref = sheet.addRow([
'',
'',
'Refer Earnings Sheet — line 12 ix) Others'
]);
ref.getCell(3).font = { italic: true, size: 10 }; ref.getCell(3).font = { italic: true, size: 10 };
} }
File diff suppressed because it is too large Load Diff
@@ -52,6 +52,46 @@
} }
</div> </div>
<!-- GL mapping toolbar (applies to the first figure column of each statement) -->
@if (activeTab !== 'filing' && activeTab !== 'signatory') {
<div class="oss-gl-bar" [class.on]="glMapMode">
<div class="oss-gl-bar-l">
<fa-icon icon="project-diagram"></fa-icon>
<span>
<strong>General Ledger mapping</strong>
@if (glMappedCount) {
<span class="oss-gl-count">{{ glMappedCount }} line(s) mapped</span>
} @else {
<span class="muted"> — pin GL accounts to a line and its first figure comes from the ledger</span>
}
</span>
</div>
<div class="oss-gl-bar-r">
@if (glMapMode) {
@if (canSelectGlBranch) {
<select class="oss-gl-inp" [(ngModel)]="glBranchCode">
<option value="">All branches</option>
@for (b of glBranches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
}
</select>
} @else {
<span class="oss-gl-scope" title="Your login is restricted to this branch">
<fa-icon icon="university"></fa-icon> {{ glScopeName }}
</span>
}
@if (glMappedCount) {
<button class="oss-btn oss-btn-clear" (click)="clearAllGlMappings()">Clear all</button>
}
}
<button class="oss-btn" [class.oss-btn-xls]="!glMapMode" (click)="toggleGlMapMode()">
<fa-icon [icon]="glMapMode ? 'times' : 'plus'"></fa-icon>
{{ glMapMode ? 'Done' : 'Add / Remove GLs' }}
</button>
</div>
</div>
}
<!-- FILING INFORMATION --> <!-- FILING INFORMATION -->
@if (activeTab === 'filing') { @if (activeTab === 'filing') {
<div class="oss-section"> <div class="oss-section">
@@ -74,7 +114,9 @@
<!-- PORTFOLIO ANALYSIS --> <!-- PORTFOLIO ANALYSIS -->
@if (activeTab === 'portfolio') { @if (activeTab === 'portfolio') {
<div class="oss-section"> <div class="oss-section">
<div class="oss-sheet-banner">PortfolioAnalysis(loans_advncs) — Portfolio Analysis (By delinquency in Interest Payment)</div> <div class="oss-sheet-banner">
PortfolioAnalysis(loans_advncs) — Portfolio Analysis (By delinquency in Interest Payment)
</div>
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: portfolioAnalysis }"></ng-container> <ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: portfolioAnalysis }"></ng-container>
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: portfolioMemo }"></ng-container> <ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: portfolioMemo }"></ng-container>
</div> </div>
@@ -117,7 +159,9 @@
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: capitalMemo }"></ng-container> <ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: capitalMemo }"></ng-container>
<div class="oss-tbl-wrap"> <div class="oss-tbl-wrap">
<div class="oss-tbl-h">Table 5: Top Fifty Impaired Credit or more than Rs.100 lakhs &mdash; Rs in Thousands</div> <div class="oss-tbl-h">
Table 5: Top Fifty Impaired Credit or more than Rs.100 lakhs &mdash; Rs in Thousands
</div>
<table class="oss-tbl"> <table class="oss-tbl">
<thead> <thead>
<tr> <tr>
@@ -240,7 +284,8 @@
Sectoral gross advances <strong>&#8377;{{ sectoralGross | number: '1.2-2' }}</strong> and NPAs Sectoral gross advances <strong>&#8377;{{ sectoralGross | number: '1.2-2' }}</strong> and NPAs
<strong>&#8377;{{ sectoralNpa | number: '1.2-2' }}</strong> do not match the closing position reported by <strong>&#8377;{{ sectoralNpa | number: '1.2-2' }}</strong> do not match the closing position reported by
Classification (<strong>&#8377;{{ classificationTotal | number: '1.2-2' }}</strong> / Classification (<strong>&#8377;{{ classificationTotal | number: '1.2-2' }}</strong> /
<strong>&#8377;{{ grossNpa | number: '1.2-2' }}</strong>). <strong>&#8377;{{ grossNpa | number: '1.2-2' }}</strong
>).
@if (sectoralMatchesOpening) { @if (sectoralMatchesOpening) {
They match the <em>opening</em> balances in Movement Parts 1 and 2 instead — this statement appears to They match the <em>opening</em> balances in Movement Parts 1 and 2 instead — this statement appears to
have been prepared on the previous quarter's book. have been prepared on the previous quarter's book.
@@ -326,7 +371,10 @@
<ng-template #matrix let-t="t"> <ng-template #matrix let-t="t">
<div class="oss-tbl-wrap"> <div class="oss-tbl-wrap">
<div class="oss-tbl-h"> <div class="oss-tbl-h">
{{ t.title }}@if (t.unit) {<span class="oss-tbl-unit">{{ t.unit }}</span>} {{ t.title }}
@if (t.unit) {
<span class="oss-tbl-unit">{{ t.unit }}</span>
}
</div> </div>
@if (t.subtitle) { @if (t.subtitle) {
<div class="oss-tbl-sub">{{ t.subtitle }}</div> <div class="oss-tbl-sub">{{ t.subtitle }}</div>
@@ -335,6 +383,9 @@
<thead> <thead>
<tr> <tr>
<th class="oss-col-label">Particulars</th> <th class="oss-col-label">Particulars</th>
@if (glMapMode) {
<th class="oss-gl-col">Mapped GL accounts</th>
}
@for (c of t.columns; track $index) { @for (c of t.columns; track $index) {
<th class="ta-r">{{ c }}</th> <th class="ta-r">{{ c }}</th>
} }
@@ -344,12 +395,48 @@
@for (row of t.rows; track $index) { @for (row of t.rows; track $index) {
<tr [class.total-row]="row.total" [class.section-row]="row.header"> <tr [class.total-row]="row.total" [class.section-row]="row.header">
<td [class]="'lvl-' + row.level">{{ row.label }}</td> <td [class]="'lvl-' + row.level">{{ row.label }}</td>
@if (glMapMode) {
<td>
@if (!row.header) {
<div class="oss-gl-cell">
@for (code of glCodesFor(t.title + ' / ' + row.label); track code) {
<span class="oss-gl-chip">
<span class="mono">{{ code }}</span>
<button
class="oss-gl-chip-x"
(click)="removeGlFromLine(t.title + ' / ' + row.label, code)"
[attr.aria-label]="'Remove ' + code"
>
<fa-icon icon="times"></fa-icon>
</button>
</span>
}
<button
class="oss-gl-add"
(click)="openGlPicker(t.title + ' / ' + row.label, row.values[0] ?? null)"
>
<fa-icon icon="plus"></fa-icon>
{{ glCodesFor(t.title + ' / ' + row.label).length ? 'Edit' : 'Add GL' }}
</button>
</div>
}
</td>
}
@if (row.header) { @if (row.header) {
<td [attr.colspan]="t.columns.length"></td> <td [attr.colspan]="t.columns.length"></td>
} @else { } @else {
@for (v of row.values; track $index) { @for (v of row.values; track $index) {
<td class="ta-r mono" [class.derived]="row.total"> <td
{{ v === null ? '' : (v | number: '1.2-2') }}{{ v !== null && row.percent ? '%' : '' }} class="ta-r mono"
[class.derived]="row.total"
[class.gl-sourced]="$index === 0 && isGlMapped(t.title + ' / ' + row.label)"
[class.gl-diff]="$index === 0 && glDiffers(t.title + ' / ' + row.label, v)"
>
@if ($index === 0 && isGlMapped(t.title + ' / ' + row.label)) {
{{ effectiveValue(t.title + ' / ' + row.label, v) | number: '1.2-2' }}{{ row.percent ? '%' : '' }}
} @else {
{{ v === null ? '' : (v | number: '1.2-2') }}{{ v !== null && row.percent ? '%' : '' }}
}
</td> </td>
} }
} }
@@ -12,6 +12,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import * as ExcelJS from 'exceljs'; import * as ExcelJS from 'exceljs';
import { MatrixTable } from '../oss-return.model'; import { MatrixTable } from '../oss-return.model';
import { GlMappingHost } from '../gl-mapping.host';
import { import {
addBanner, addBanner,
addMatrixTable, addMatrixTable,
@@ -77,7 +78,14 @@ type Oss3Tab =
FormsModule FormsModule
] ]
}) })
export class Oss3RaqComponent { export class Oss3RaqComponent extends GlMappingHost {
readonly glReturnCode = 'R323';
/** RAQ reports the quarter; mapped figures are drawn from the same span. */
get glRange() {
return { from: '2026-04-01', to: '2026-06-30' };
}
activeTab: Oss3Tab = 'filing'; activeTab: Oss3Tab = 'filing';
/** Tab strip definition — keeps the template free of a dozen near-identical buttons. */ /** Tab strip definition — keeps the template free of a dozen near-identical buttons. */
@@ -313,8 +321,7 @@ export class Oss3RaqComponent {
{ width: 16 }, { width: 16 },
{ width: 18 }, { width: 18 },
{ width: 18 }, { width: 18 },
{ width: 18 } { width: 18 }];
];
addBanner(sheet, 'TopImpairedCredits - Top Impaired Credits', 'I'); addBanner(sheet, 'TopImpairedCredits - Top Impaired Credits', 'I');
addMatrixTable(sheet, this.capitalMemo); addMatrixTable(sheet, this.capitalMemo);
for (const t of this.recoveryTables) { for (const t of this.recoveryTables) {
@@ -398,8 +405,7 @@ export class Oss3RaqComponent {
{ width: 18 }, { width: 18 },
{ width: 20 }, { width: 20 },
{ width: 22 }, { width: 22 },
{ width: 20 } { width: 20 }];
];
addBanner(sheet, 'ExposureLimits - Single and Group Borrower Exposure Limit', 'G'); addBanner(sheet, 'ExposureLimits - Single and Group Borrower Exposure Limit', 'G');
addMatrixTable(sheet, this.capitalPreviousMarch); addMatrixTable(sheet, this.capitalPreviousMarch);
@@ -0,0 +1,703 @@
/**
* 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 data layer for the "Reconciliation" mega-menu — 16 screens across 5 groups (Core &
// Books / Bank & Clearing / Digital & Payments / Instruments & Members / Compliance &
// Financials). Every reconciliation type shares one generic two-sided model (Side A vs Side B,
// difference, match status) so the UI, KPIs and "Mark as Reconciled" action are all driven by
// the same pure functions below — no report can duplicate/contradict another's numbers.
//
// Branches are the SAME 8 used by the NPA module (Branch 1/Branch 2 + 6 named branches) so a
// real bank demo never shows two different branch lists in two different menus.
import { BRANCHES, BranchMaster } from '../npa/npa-data';
export { BRANCHES };
export type { BranchMaster };
export type ReconGroupId = 'core' | 'bank' | 'digital' | 'instruments' | 'compliance';
export type ReconScope = 'BRANCH' | 'HEAD_OFFICE';
export type ReconStatus = 'MATCHED' | 'UNMATCHED' | 'PENDING' | 'RESOLVED';
export const GROUP_LABELS: Record<ReconGroupId, string> = {
core: 'Core & Books',
bank: 'Bank & Clearing',
digital: 'Digital & Payments',
instruments: 'Instruments & Members',
compliance: 'Compliance & Financials'
};
export interface ReconTypeMeta {
id: string;
group: ReconGroupId;
label: string;
/**
* BRANCH — the underlying activity is booked at a specific branch (vouchers, cheques,
* customer digital transactions, share members, fixed assets), so Head Office sees a branch
* selector + consolidated view and a branch login is locked to its own branch — same pattern
* as the NPA module.
* HEAD_OFFICE — the activity has no branch dimension in real life (bank-wide P&L/GL control,
* treasury's own bank accounts, statutory tax filing, vendor procurement, inter-branch
* consolidation) — only Head Office can open these; a branch login sees a restricted notice.
*/
scope: ReconScope;
sideALabel: string;
sideBLabel: string;
referenceLabel: string;
/** Only set for the one genuinely three-sided report (Three-way Digital Reconciliation). */
extraLabel?: string;
}
export const RECON_TYPES: ReconTypeMeta[] = [
{
id: 'voucher',
group: 'core',
label: 'Voucher Reconciliation',
scope: 'BRANCH',
sideALabel: 'System Voucher Amount',
sideBLabel: 'Physical / Scanned Voucher Amount',
referenceLabel: 'Voucher No'
},
{
id: 'sundry-dr-cr',
group: 'core',
label: 'Sundry Dr / Cr Reconciliation',
scope: 'BRANCH',
sideALabel: 'GL Balance',
sideBLabel: 'Party Ledger Balance',
referenceLabel: 'Party Code'
},
{
id: 'pnl-impact',
group: 'core',
label: 'P&L Impact Reconciliation',
scope: 'HEAD_OFFICE',
sideALabel: 'P&L as per GL',
sideBLabel: 'P&L as per MIS Schedule',
referenceLabel: 'Income / Expense Head'
},
{
id: 'gl-reconciliation',
group: 'core',
label: 'GL Reconciliation',
scope: 'HEAD_OFFICE',
sideALabel: 'Total Debit',
sideBLabel: 'Total Credit',
referenceLabel: 'GL Code'
},
{
id: 'fixed-assets',
group: 'core',
label: 'Fixed Assets Reconciliation',
scope: 'BRANCH',
sideALabel: 'GL Asset Value (WDV)',
sideBLabel: 'Physical Verification Value',
referenceLabel: 'Asset Tag No'
},
{
id: 'inter-company',
group: 'core',
label: 'Inter-Company Reconciliation',
scope: 'HEAD_OFFICE',
sideALabel: 'HO Books (Inter-Branch A/c)',
sideBLabel: 'Branch Books (HO A/c)',
referenceLabel: 'Branch'
},
{
id: 'bank-reco',
group: 'bank',
label: 'Bank Reconciliation',
scope: 'HEAD_OFFICE',
sideALabel: 'Balance per Bank GL (Books)',
sideBLabel: 'Balance per Bank Statement',
referenceLabel: 'Bank Account'
},
{
id: 'api-bank-reco',
group: 'bank',
label: 'API Based Bank Reconciliation',
scope: 'HEAD_OFFICE',
sideALabel: 'Core Banking Balance',
sideBLabel: 'API Feed Balance',
referenceLabel: 'Bank Account (API)'
},
{
id: 'neft-rtgs',
group: 'bank',
label: 'NEFT / RTGS Reconciliation',
scope: 'BRANCH',
sideALabel: 'Amount per CBS',
sideBLabel: 'Amount per RBI Settlement File',
referenceLabel: 'UTR No'
},
{
id: 'clearing-reco',
group: 'bank',
label: 'Clearing Reconciliation',
scope: 'BRANCH',
sideALabel: 'Cheque Amount per CBS',
sideBLabel: 'Amount per Clearing House (CTS)',
referenceLabel: 'Cheque No'
},
{
id: 'udir',
group: 'digital',
label: 'UDIR (IMPS / UPI / ATM)',
scope: 'BRANCH',
sideALabel: 'Disputed Amount (Customer Claim)',
sideBLabel: 'NPCI UDIR Resolution Amount',
referenceLabel: 'UDIR Ticket No'
},
{
id: 'three-way-digital',
group: 'digital',
label: 'Three-way Digital Reconciliation',
scope: 'HEAD_OFFICE',
sideALabel: 'Core Banking Record',
sideBLabel: 'Settlement Bank Record',
extraLabel: 'Switch / NPCI Record',
referenceLabel: 'Transaction Ref (RRN)'
},
{
id: 'nach-reco',
group: 'digital',
label: 'NACH Reconciliation',
scope: 'BRANCH',
sideALabel: 'Mandate Amount per CBS',
sideBLabel: 'Amount per NPCI Response File',
referenceLabel: 'UMRN'
},
{
id: 'share-member',
group: 'instruments',
label: 'Share Member Reconciliation',
scope: 'BRANCH',
sideALabel: 'Share GL Balance',
sideBLabel: 'Member Share Ledger Total',
referenceLabel: 'Folio No'
},
{
id: 'invoice-payment',
group: 'compliance',
label: 'Invoice Payment',
scope: 'HEAD_OFFICE',
sideALabel: 'Invoice Amount (Vendor Bill)',
sideBLabel: 'Amount Paid per GL',
referenceLabel: 'Invoice No'
},
{
id: 'tax-reco',
group: 'compliance',
label: 'Tax Reconciliation',
scope: 'HEAD_OFFICE',
sideALabel: 'Deducted / Collected per Books',
sideBLabel: 'Remitted to Government (Challan)',
referenceLabel: 'Challan No'
}
];
export const DEFAULT_RECON_ID = 'voucher';
export function reconMeta(id: string): ReconTypeMeta {
return RECON_TYPES.find((t) => t.id === id) || RECON_TYPES[0];
}
export interface ReconItem {
id: string;
reconTypeId: string;
branchCode: string;
branchName: string;
groupLabel: string;
reference: string;
description: string;
date: string;
sideAAmount: number;
sideBAmount: number;
extraAmount?: number;
status: ReconStatus;
remarks: string;
}
export function difference(item: ReconItem): number {
return +(item.sideAAmount - item.sideBAmount).toFixed(2);
}
export function isOpen(item: ReconItem): boolean {
return item.status === 'UNMATCHED' || item.status === 'PENDING';
}
// -------------------------------------------------------------------------
// Date helpers (same shape as npa-data.ts, kept local so this module has no
// runtime dependency beyond the shared BRANCHES list)
// -------------------------------------------------------------------------
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;
}
export function todayDDMMYYYY(): string {
return fmt(new Date());
}
// -------------------------------------------------------------------------
// Seeded RNG — deterministic dataset across reloads (own seed, independent of npa-data.ts)
// -------------------------------------------------------------------------
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(20260213);
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);
const VENDOR_NAMES = [
'Om Stationery Suppliers',
'Shubham Printers',
'Shree Balaji AMC Services',
'Metro Security Services',
'Sai Computers & Networking',
'Deccan Furniture Works',
'Unity Housekeeping Services',
'Precision Fire & Safety',
'Konark Electricals',
'Vasant Stationery Mart'
];
const PARTY_NAMES = [
'Kiran Hardware',
'Vishal Electronics',
'Nilesh Electricals',
'Ramdas Plumbing Works',
'Anita Traders',
'Ganesh Enterprises',
'Om Textiles',
'Balaji Agro Traders',
'Sunil Auto Parts',
'Meera Fabricators'
];
const ASSET_NAMES = [
'Office Furniture Set',
'Desktop Computer (Branch Counter)',
'UPS 3 KVA',
'Currency Counting Machine',
'Air Conditioner 1.5 Ton',
'CCTV Camera System',
'Branch Generator 15 KVA',
'Server Rack Cabinet',
'Biometric Attendance Device',
'Two-Wheeler (Recovery Officer)'
];
const GL_HEADS = [
'Interest on Loans & Advances',
'Interest on Investments',
'Interest Paid on Deposits',
'Commission & Exchange',
'Locker Rent Income',
'Establishment Charges',
'Rent, Taxes & Lighting',
'Printing & Stationery',
'Depreciation on Fixed Assets',
'Bad Debts Written Off',
'Audit Fees',
'Sundry Income'
];
const BANK_ACCOUNTS = [
'SBI Current Account - 40012345678',
'HDFC Bank Current Account - 5020012345',
'ICICI Bank Current Account - 000305012345',
'Axis Bank Current Account - 91020012345',
'RBI Current Account (Banking Dept)'
];
interface GenOptions {
reconTypeId: string;
refPrefix: string;
refDigits: number;
descriptions: string[];
minAmt: number;
maxAmt: number;
unmatchedRate: number;
branchScoped: boolean;
countPerBranch?: number;
totalCount?: number;
today: Date;
}
function statusFor(unmatchedRate: number): ReconStatus {
const r = rng();
if (r < unmatchedRate * 0.6) {
return 'UNMATCHED';
}
if (r < unmatchedRate) {
return 'PENDING';
}
return 'MATCHED';
}
function amountsFor(status: ReconStatus, minAmt: number, maxAmt: number): { a: number; b: number } {
const a = round(between(minAmt, maxAmt));
if (status === 'MATCHED') {
return { a, b: a };
}
const gapPct = between(0.02, 0.35);
const sign = rng() < 0.5 ? 1 : -1;
const b = round(a * (1 - sign * gapPct));
return { a, b };
}
function generateGeneric(opts: GenOptions): ReconItem[] {
const items: ReconItem[] = [];
const meta = reconMeta(opts.reconTypeId);
const branchLoop = opts.branchScoped ? BRANCHES : [{ code: 'HO', name: 'Head Office' }];
const perBranch = opts.countPerBranch ?? opts.totalCount ?? 20;
branchLoop.forEach((branch, branchIdx) => {
const count = opts.branchScoped ? perBranch : (opts.totalCount ?? perBranch);
for (let i = 0; i < count; i++) {
const status = statusFor(opts.unmatchedRate);
const { a, b } = amountsFor(status, opts.minAmt, opts.maxAmt);
const daysAgo = round(between(1, 75));
const seq = String(branchIdx * 1000 + i + 1).padStart(opts.refDigits, '0');
items.push({
id: `${opts.reconTypeId}-${branch.code}-${i}`,
reconTypeId: opts.reconTypeId,
branchCode: branch.code,
branchName: branch.name,
groupLabel: GROUP_LABELS[meta.group],
reference: `${opts.refPrefix}${seq}`,
description: pick(opts.descriptions),
date: fmt(addDays(opts.today, -daysAgo)),
sideAAmount: a,
sideBAmount: status === 'MATCHED' ? a : b,
status,
remarks: status === 'MATCHED' ? '' : ''
});
}
});
return items;
}
function generateInterCompany(today: Date): ReconItem[] {
return BRANCHES.map((b): ReconItem => {
const status = statusFor(0.25);
const { a, b: bAmt } = amountsFor(status, 200000, 3500000);
return {
id: `inter-company-${b.code}`,
reconTypeId: 'inter-company',
branchCode: 'HO',
branchName: 'Head Office',
groupLabel: GROUP_LABELS.core,
reference: b.name,
description: `Inter-branch settlement account — ${b.name}`,
date: fmt(addDays(today, -round(between(1, 10)))),
sideAAmount: a,
sideBAmount: status === 'MATCHED' ? a : bAmt,
status,
remarks: ''
};
});
}
function generateBankReco(reconTypeId: string, today: Date, refPrefix: string): ReconItem[] {
const templates = [
'Cheque issued, not yet presented',
'Cheque deposited, not yet credited by bank',
'Bank charges debited, not yet booked in CBS',
'Interest credited by bank, not yet booked in CBS',
'Direct credit by remitter, not yet identified',
'Standing instruction debited by bank, pending booking',
'RTGS outward debited by bank, in-transit in CBS'
];
const items: ReconItem[] = [];
BANK_ACCOUNTS.forEach((acct, idx) => {
const lineCount = reconTypeId === 'api-bank-reco' ? 5 : 6;
for (let i = 0; i < lineCount; i++) {
const status = statusFor(0.55);
const { a, b } = amountsFor(status, 15000, 850000);
items.push({
id: `${reconTypeId}-${idx}-${i}`,
reconTypeId,
branchCode: 'HO',
branchName: 'Head Office',
groupLabel: GROUP_LABELS.bank,
reference: `${refPrefix}${String(idx + 1).padStart(2, '0')}${String(i + 1).padStart(3, '0')}`,
description: `${acct} — ${pick(templates)}`,
date: fmt(addDays(today, -round(between(1, 30)))),
sideAAmount: a,
sideBAmount: status === 'MATCHED' ? a : b,
status,
remarks: ''
});
}
});
return items;
}
function generateThreeWayDigital(today: Date): ReconItem[] {
const channels = [
'IMPS',
'UPI',
'NEFT',
'RTGS',
'ATM (NFS)'
];
const items: ReconItem[] = [];
for (let i = 0; i < 45; i++) {
const status = statusFor(0.12);
const core = round(between(500, 250000));
const switchAmt = status === 'MATCHED' ? core : round(core * (1 - between(0.02, 0.2)));
const settlement = status === 'MATCHED' ? core : round(core * (1 - between(0.02, 0.15)));
items.push({
id: `three-way-digital-${i}`,
reconTypeId: 'three-way-digital',
branchCode: 'HO',
branchName: 'Head Office',
groupLabel: GROUP_LABELS.digital,
reference: `RRN${String(700000 + i)}`,
description: `${pick(channels)} transaction — Core vs Switch vs Settlement Bank`,
date: fmt(addDays(today, -round(between(1, 20)))),
sideAAmount: core,
sideBAmount: settlement,
extraAmount: switchAmt,
status,
remarks: ''
});
}
return items;
}
function generateAll(): ReconItem[] {
const today = new Date();
return [
...generateGeneric({
reconTypeId: 'voucher',
refPrefix: 'JV/26/',
refDigits: 5,
descriptions: [
'Cash deposit voucher',
'Cash withdrawal voucher',
'Journal entry — interest provision',
'Journal entry — expense booking',
'Transfer voucher — inter-GL',
'Debit voucher — charges recovery',
'Credit voucher — refund adjustment'
],
minAmt: 500,
maxAmt: 250000,
unmatchedRate: 0.08,
branchScoped: true,
countPerBranch: 15,
today
}),
...generateGeneric({
reconTypeId: 'sundry-dr-cr',
refPrefix: 'PTY-',
refDigits: 4,
descriptions: PARTY_NAMES,
minAmt: 5000,
maxAmt: 180000,
unmatchedRate: 0.18,
branchScoped: true,
countPerBranch: 10,
today
}),
...generateGeneric({
reconTypeId: 'pnl-impact',
refPrefix: 'GLH-',
refDigits: 3,
descriptions: GL_HEADS,
minAmt: 40000,
maxAmt: 4200000,
unmatchedRate: 0.15,
branchScoped: false,
totalCount: 24,
today
}),
...generateGeneric({
reconTypeId: 'gl-reconciliation',
refPrefix: 'GL-',
refDigits: 6,
descriptions: [
'Suspense account clearance check',
'Control account tally — deposits',
'Control account tally — advances',
'Inter-branch adjustment head',
'Sundry assets/liabilities head',
...GL_HEADS
],
minAmt: 20000,
maxAmt: 5200000,
unmatchedRate: 0.1,
branchScoped: false,
totalCount: 42,
today
}),
...generateGeneric({
reconTypeId: 'fixed-assets',
refPrefix: 'FA-',
refDigits: 4,
descriptions: ASSET_NAMES,
minAmt: 8000,
maxAmt: 650000,
unmatchedRate: 0.12,
branchScoped: true,
countPerBranch: 8,
today
}),
...generateInterCompany(today),
...generateBankReco('bank-reco', today, 'BRS-'),
...generateBankReco('api-bank-reco', today, 'API-'),
...generateGeneric({
reconTypeId: 'neft-rtgs',
refPrefix: 'UTR',
refDigits: 12,
descriptions: [
'RTGS outward — beneficiary bank settlement',
'RTGS inward — remitting bank settlement',
'NEFT outward — beneficiary bank settlement',
'NEFT inward — remitting bank settlement'
],
minAmt: 25000,
maxAmt: 1800000,
unmatchedRate: 0.06,
branchScoped: true,
countPerBranch: 12,
today
}),
...generateGeneric({
reconTypeId: 'clearing-reco',
refPrefix: 'CHQ-',
refDigits: 6,
descriptions: [
'Outward clearing — cheque sent',
'Inward clearing — cheque received',
'CTS grid settlement adjustment',
'Cheque return — insufficient funds'
],
minAmt: 10000,
maxAmt: 620000,
unmatchedRate: 0.09,
branchScoped: true,
countPerBranch: 10,
today
}),
...generateGeneric({
reconTypeId: 'udir',
refPrefix: 'UDIR-',
refDigits: 5,
descriptions: [
'IMPS failed but debited — customer dispute',
'UPI transaction disputed — merchant not credited',
'ATM cash-not-dispensed but account debited',
'UPI duplicate debit dispute'
],
minAmt: 500,
maxAmt: 45000,
unmatchedRate: 0.35,
branchScoped: true,
countPerBranch: 4,
today
}),
...generateThreeWayDigital(today),
...generateGeneric({
reconTypeId: 'nach-reco',
refPrefix: 'UMRN',
refDigits: 10,
descriptions: [
'NACH debit — loan EMI collection',
'NACH debit — gold loan EMI collection',
'NACH credit — salary/DBT disbursement',
'NACH mandate return — insufficient funds'
],
minAmt: 1500,
maxAmt: 85000,
unmatchedRate: 0.14,
branchScoped: true,
countPerBranch: 8,
today
}),
...generateGeneric({
reconTypeId: 'share-member',
refPrefix: 'FOL-',
refDigits: 5,
descriptions: [
'Regular member — share balance',
'Nominal member — share balance',
'Share transfer on account closure',
'Share application pending allotment'
],
minAmt: 500,
maxAmt: 25000,
unmatchedRate: 0.07,
branchScoped: true,
countPerBranch: 9,
today
}),
...generateGeneric({
reconTypeId: 'invoice-payment',
refPrefix: 'INV-',
refDigits: 5,
descriptions: VENDOR_NAMES,
minAmt: 3000,
maxAmt: 380000,
unmatchedRate: 0.13,
branchScoped: false,
totalCount: 30,
today
}),
...generateGeneric({
reconTypeId: 'tax-reco',
refPrefix: 'CHLN-',
refDigits: 4,
descriptions: [
'TDS on FD/RD interest payouts',
'TDS on salary payments',
'TDS on contractor/vendor payments',
'GST on service charges collected',
'GST on locker rent collected',
'TCS on cash transactions'
],
minAmt: 8000,
maxAmt: 620000,
unmatchedRate: 0.11,
branchScoped: false,
totalCount: 18,
today
})
];
}
export const RECON_ITEMS: ReconItem[] = generateAll();
// -------------------------------------------------------------------------
// KPI helpers — always computed from the item list handed in, so a filtered
// (branch-scoped) view and the full book can never disagree on a total.
// -------------------------------------------------------------------------
export function totalSideA(items: ReconItem[]): number {
return items.reduce((s, i) => s + i.sideAAmount, 0);
}
export function totalSideB(items: ReconItem[]): number {
return items.reduce((s, i) => s + i.sideBAmount, 0);
}
export function totalDifference(items: ReconItem[]): number {
return items.reduce((s, i) => s + Math.abs(difference(i)), 0);
}
export function countByStatus(items: ReconItem[], status: ReconStatus): number {
return items.filter((i) => i.status === status).length;
}
export function matchRate(items: ReconItem[]): number {
return items.length ? (countByStatus(items, 'MATCHED') / items.length) * 100 : 100;
}
@@ -0,0 +1,271 @@
<!--
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">
RECONCILIATION · {{ groupLabels[activeGroup] | uppercase }}
<span class="recon-scope-chip">{{ meta.scope === 'HEAD_OFFICE' ? 'Head Office Only' : 'Branch-wise' }}</span>
</div>
<h1 class="pg-title">{{ meta.label }}</h1>
<p class="pg-sub">
{{ meta.sideALabel }} vs {{ meta.sideBLabel }}
@if (meta.extraLabel) {
vs {{ meta.extraLabel }}
}
— as on {{ asOnDate }}.
</p>
</div>
<div class="pg-toolbar">
@if (meta.scope === 'BRANCH') {
@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>
}
} @else {
<label class="pg-field">
<span>Scope</span>
<span class="pg-chip">Head Office (Whole Bank)</span>
</label>
}
<label class="pg-field">
<span>As On</span>
<input type="text" [(ngModel)]="asOnDate" placeholder="DD-MM-YYYY" />
</label>
<button type="button" class="pg-btn pg-btn--primary" (click)="generate()">Generate</button>
@if (!isRestricted) {
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="openExportModal()">
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
}
</div>
</header>
@if (!isRestricted) {
<div class="inv-kpis">
<div class="inv-kpi">
<span class="inv-kpi__label">{{ meta.sideALabel }}</span>
<span class="inv-kpi__value">₹{{ totalA | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">{{ items.length }} items</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">{{ meta.sideBLabel }}</span>
<span class="inv-kpi__value">₹{{ totalB | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">{{ scopeName }}</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Absolute Difference</span>
<span class="inv-kpi__value" [class.npa-warn]="totalDiff > 0">₹{{ totalDiff | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">{{ matchPct | number: '1.1-1' }}% matched</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Open Items</span>
<span class="inv-kpi__value npa-warn">{{ unmatchedCount + pendingCount }}</span>
<span class="inv-kpi__sub"
>{{ unmatchedCount }} unmatched · {{ pendingCount }} pending · {{ resolvedCount }} resolved</span
>
</div>
</div>
}
<div class="cctr-tabs">
@for (g of groups; track g) {
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeGroup === g"
(click)="goToReport(groupOf(g)[0].id)"
>
{{ groupLabels[g] }}
</button>
}
</div>
<div class="cctr-tabs">
@for (m of groupOf(activeGroup); track m.id) {
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === m.id"
(click)="goToReport(m.id)"
>
{{ m.label }}
</button>
}
</div>
@if (isRestricted) {
<div class="pg-card">
<div class="recon-restricted">
<fa-icon icon="university" class="recon-restricted__icon"></fa-icon>
<div class="recon-restricted__title">Managed Centrally by Head Office</div>
<p class="recon-restricted__body">
{{ meta.label }} has no branch-level dimension in real-world banking — it is reconciled centrally by the Head
Office Accounts/Treasury team. Your login ({{ currentOfficeName }}) does not have access to this screen.
Contact Head Office if you need a figure from this reconciliation.
</p>
</div>
</div>
} @else {
<div class="pg-card">
<div class="pos-section-label">{{ meta.label }} — {{ scopeName }}</div>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>{{ meta.referenceLabel }}</th>
<th>Description</th>
@if (meta.scope === 'BRANCH') {
<th>Branch</th>
}
<th>Date</th>
<th class="num">{{ meta.sideALabel }}</th>
<th class="num">{{ meta.sideBLabel }}</th>
@if (meta.extraLabel) {
<th class="num">{{ meta.extraLabel }}</th>
}
<th class="num">Difference</th>
<th>Status</th>
<th>Remarks / Action</th>
</tr>
</thead>
<tbody>
@for (item of paged(items); track item.id) {
<tr>
<td>
<span class="pg-chip">{{ item.reference }}</span>
</td>
<td>{{ item.description }}</td>
@if (meta.scope === 'BRANCH') {
<td>{{ item.branchName }}</td>
}
<td>{{ item.date }}</td>
<td class="num">₹{{ item.sideAAmount | number: '1.0-0' }}</td>
<td class="num">₹{{ item.sideBAmount | number: '1.0-0' }}</td>
@if (meta.extraLabel) {
<td class="num">₹{{ item.extraAmount | number: '1.0-0' }}</td>
}
<td class="num" [class.col-out]="diff(item) !== 0" [class.col-in]="diff(item) === 0">
₹{{ diff(item) | number: '1.0-0' }}
</td>
<td>
<span [class]="badgeClass(item.status)">{{ item.status }}</span>
</td>
<td>
@if (isItemOpen(item)) {
<div class="recon-action-row">
<input
type="text"
class="recon-remark-input"
[(ngModel)]="remarksDraft[item.id]"
placeholder="Remarks (optional)"
/>
<button type="button" class="pg-btn pg-btn--ghost" (click)="markReconciled(item)">
Mark as Reconciled
</button>
</div>
} @else if (item.status === 'RESOLVED') {
<span class="pg-sub">{{ item.remarks }}</span>
}
</td>
</tr>
} @empty {
<tr>
<td
[attr.colspan]="meta.scope === 'BRANCH' ? (meta.extraLabel ? 10 : 9) : meta.extraLabel ? 9 : 8"
class="pg-empty"
>
No {{ meta.label }} items for this branch.
</td>
</tr>
}
</tbody>
</table>
</div>
@if (items.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(items) }}–{{ rangeEnd(items) }} of {{ items.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, items)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(items) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(items)"
(click)="goToPage(currentPage + 1, items)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
</div>
@if (showExportModal) {
<div class="recon-modal-backdrop" (click)="closeExportModal()">
<div class="recon-modal" (click)="$event.stopPropagation()">
<div class="recon-modal__header">
<span>Export to Excel — Sign-off (Optional)</span>
<button type="button" class="recon-modal__close" (click)="closeExportModal()" aria-label="Close">
&times;
</button>
</div>
<p class="pg-sub" style="padding: 0 20px">
Fill in Prepared / Checked / Approved details if this copy needs them for the record — or leave them blank and
download straight away.
</p>
<div class="recon-signoff-grid">
<div class="recon-signoff-col">
<span class="recon-signoff-role">Prepared By</span>
<input type="text" placeholder="Name" [(ngModel)]="exportSignOff.preparedByName" />
<input type="text" placeholder="Designation" [(ngModel)]="exportSignOff.preparedByDesg" />
</div>
<div class="recon-signoff-col">
<span class="recon-signoff-role">Checked By</span>
<input type="text" placeholder="Name" [(ngModel)]="exportSignOff.checkedByName" />
<input type="text" placeholder="Designation" [(ngModel)]="exportSignOff.checkedByDesg" />
</div>
<div class="recon-signoff-col">
<span class="recon-signoff-role">Approved By</span>
<input type="text" placeholder="Name" [(ngModel)]="exportSignOff.approvedByName" />
<input type="text" placeholder="Designation" [(ngModel)]="exportSignOff.approvedByDesg" />
</div>
</div>
<div class="inv-form-actions" style="padding: 0 20px 20px">
<button type="button" class="pg-btn pg-btn--ghost" (click)="closeExportModal()">Cancel</button>
<button type="button" class="pg-btn pg-btn--primary" (click)="confirmExport()">Download</button>
</div>
</div>
</div>
}
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</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, inject, OnDestroy, OnInit } 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 * as ExcelJS from 'exceljs';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import {
RECON_TYPES,
RECON_ITEMS,
BRANCHES,
GROUP_LABELS,
DEFAULT_RECON_ID,
ReconGroupId,
ReconTypeMeta,
ReconItem,
ReconStatus,
reconMeta,
difference,
isOpen,
totalSideA,
totalSideB,
totalDifference,
countByStatus,
matchRate,
todayDDMMYYYY
} from '../reconciliation-data';
/** Optional "Prepared / Checked / Approved" details captured in the Export to Excel popup —
* purely a one-off snapshot for that download, not a persisted workflow/sign-off state. */
interface ReconExportSignOff {
preparedByName: string;
preparedByDesg: string;
checkedByName: string;
checkedByDesg: string;
approvedByName: string;
approvedByDesg: string;
}
function blankExportSignOff(): ReconExportSignOff {
return {
preparedByName: '',
preparedByDesg: '',
checkedByName: '',
checkedByDesg: '',
approvedByName: '',
approvedByDesg: ''
};
}
/**
* The "Reconciliation" mega-menu — 16 screens across 5 groups (Core & Books / Bank & Clearing /
* Digital & Payments / Instruments & Members / Compliance & Financials) — collapsed into one
* route-param-driven component, mirroring the NPA module's pattern. Every screen shares the same
* generic two-sided (Side A vs Side B) reconciliation model in reconciliation-data.ts, and the
* same Head-Office-vs-branch access split used by NPA and Cash/Clearing/Transfer: activity that
* genuinely happens at a branch (vouchers, cheques, digital transactions, share members, fixed
* assets) is branch-scoped; activity with no branch dimension in real life (bank-wide P&L/GL
* control, treasury's own bank accounts, statutory tax filing, vendor procurement, inter-branch
* consolidation) is Head-Office-only.
*/
@Component({
selector: 'mifosx-reconciliation-report',
templateUrl: './reconciliation-report.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../../investment/investment-shared.scss',
'../../npa/npa-shared.scss',
'../reconciliation-shared.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule
]
})
export class ReconciliationReportComponent implements OnInit, OnDestroy {
readonly reportMeta = RECON_TYPES;
readonly groupLabels = GROUP_LABELS;
readonly branches = BRANCHES;
readonly groups: ReconGroupId[] = [
'core',
'bank',
'digital',
'instruments',
'compliance'
];
asOnDate = todayDDMMYYYY();
activeGroup: ReconGroupId = 'core';
activeReport = DEFAULT_RECON_ID;
isHeadOffice = false;
currentOfficeName = 'Branch Office';
private ownBranchCode: string | null = null;
/** '' = All Branches (Head Office consolidated view); otherwise a BranchMaster.code. */
branchFilter = '';
readonly pageSize = 20;
currentPage = 1;
remarksDraft: Record<string, string> = {};
generating = false;
showExportModal = false;
exportSignOff: ReconExportSignOff = blankExportSignOff();
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
private sub?: Subscription;
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly auth = inject(AuthenticationService);
ngOnInit(): void {
this.sub = this.route.paramMap.subscribe((params) => {
const id = params.get('reportId') || DEFAULT_RECON_ID;
const meta = this.reportMeta.find((m) => m.id === id) || this.reportMeta[0];
this.activeReport = meta.id;
this.activeGroup = meta.group;
this.currentPage = 1;
});
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) => this.currentOfficeName.toLowerCase().includes(b.name.toLowerCase()))?.code || null;
this.branchFilter = this.ownBranchCode || this.branches[0].code;
}
}
ngOnDestroy(): void {
this.sub?.unsubscribe();
}
goToReport(reportId: string): void {
this.router.navigate([
'/reconciliation',
reportId
]);
}
groupOf(group: ReconGroupId): ReconTypeMeta[] {
return this.reportMeta.filter((m) => m.group === group);
}
setBranch(code: string): void {
this.branchFilter = code;
this.currentPage = 1;
}
get meta(): ReconTypeMeta {
return reconMeta(this.activeReport);
}
/** True when a branch-level login has opened a Head-Office-only reconciliation type. */
get isRestricted(): boolean {
return this.meta.scope === 'HEAD_OFFICE' && !this.isHeadOffice;
}
get scopeName(): string {
if (this.meta.scope === 'HEAD_OFFICE') {
return 'Head Office';
}
if (!this.isHeadOffice) {
return this.currentOfficeName;
}
return this.branchFilter ? this.branches.find((b) => b.code === this.branchFilter)?.name || '' : 'All Branches';
}
get items(): ReconItem[] {
const all = RECON_ITEMS.filter((i) => i.reconTypeId === this.activeReport);
if (this.meta.scope !== 'BRANCH' || !this.branchFilter) {
return all;
}
return all.filter((i) => i.branchCode === this.branchFilter);
}
diff(item: ReconItem): number {
return difference(item);
}
isItemOpen(item: ReconItem): boolean {
return isOpen(item);
}
badgeClass(status: ReconStatus): string {
return `recon-badge recon-badge--${status.toLowerCase()}`;
}
// -------------------- KPIs (always over the full `items` list, never the paged slice) --------------------
get totalA(): number {
return totalSideA(this.items);
}
get totalB(): number {
return totalSideB(this.items);
}
get totalDiff(): number {
return totalDifference(this.items);
}
get matchedCount(): number {
return countByStatus(this.items, 'MATCHED');
}
get unmatchedCount(): number {
return countByStatus(this.items, 'UNMATCHED');
}
get pendingCount(): number {
return countByStatus(this.items, 'PENDING');
}
get resolvedCount(): number {
return countByStatus(this.items, 'RESOLVED');
}
get matchPct(): number {
return matchRate(this.items);
}
// -------------------- Pagination --------------------
paged<T>(list: T[]): T[] {
const start = (this.currentPage - 1) * this.pageSize;
return list.slice(start, start + this.pageSize);
}
pageCount(list: unknown[]): number {
return Math.max(1, Math.ceil(list.length / this.pageSize));
}
rangeStart(list: unknown[]): number {
return list.length === 0 ? 0 : (this.currentPage - 1) * this.pageSize + 1;
}
rangeEnd(list: unknown[]): number {
return Math.min(this.currentPage * this.pageSize, list.length);
}
goToPage(page: number, list: unknown[]): void {
this.currentPage = Math.min(Math.max(1, page), this.pageCount(list));
}
// -------------------- Actions --------------------
markReconciled(item: ReconItem): void {
const remark = (this.remarksDraft[item.id] || '').trim();
item.status = 'RESOLVED';
item.remarks = remark || 'Reconciled — difference verified and squared off.';
delete this.remarksDraft[item.id];
this.toast(`${this.meta.referenceLabel} ${item.reference} marked as reconciled.`);
}
generate(): void {
this.currentPage = 1;
this.toast(`${this.meta.label} refreshed as on ${this.asOnDate}.`);
}
// -------------------- Export to Excel --------------------
/** "Export to Excel" opens this popup first — Prepared/Checked/Approved details are entirely
* optional; leaving them blank and clicking Download exports immediately with no sign-off
* rows added. */
openExportModal(): void {
this.exportSignOff = blankExportSignOff();
this.showExportModal = true;
}
closeExportModal(): void {
this.showExportModal = false;
}
confirmExport(): void {
this.showExportModal = false;
void this.exportExcel();
}
/** Strips characters Excel forbids in worksheet names (\ / ? * [ ]) and caps the length. */
private safeSheetName(name: string): string {
return name.replace(/[\\/?*[\]]/g, '-').slice(0, 31);
}
private async exportExcel(): Promise<void> {
this.generating = true;
try {
const wb = new ExcelJS.Workbook();
wb.creator = 'CBSNexora';
wb.created = new Date();
// Excel worksheet names can't contain \ / ? * [ ] or exceed 31 chars — several report
// labels here have a "/" (e.g. "NEFT / RTGS Reconciliation"), which made addWorksheet()
// throw and silently abort the whole export.
const sheet = wb.addWorksheet(this.safeSheetName(this.meta.label));
const headers = [
this.meta.referenceLabel,
'Description',
...(this.meta.scope === 'BRANCH' ? ['Branch'] : []),
'Date',
this.meta.sideALabel,
this.meta.sideBLabel,
...(this.meta.extraLabel ? [this.meta.extraLabel] : []),
'Difference',
'Status',
'Remarks'
];
const widths = [
18,
34,
...(this.meta.scope === 'BRANCH' ? [16] : []),
12,
20,
20,
...(this.meta.extraLabel ? [20] : []),
16,
14,
30
];
sheet.columns = widths.map((w) => ({ width: w }));
const title = sheet.addRow([this.meta.label]);
sheet.mergeCells(title.number, 1, title.number, 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' } };
const meta = sheet.addRow([`Scope: ${this.scopeName} · As On: ${this.asOnDate}`]);
sheet.mergeCells(meta.number, 1, meta.number, headers.length);
meta.font = { italic: true, size: 10 };
sheet.addRow([]);
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' } };
});
for (const item of this.items) {
const row: (string | number | null)[] = [
item.reference,
item.description,
...(this.meta.scope === 'BRANCH' ? [item.branchName] : []),
item.date,
item.sideAAmount,
item.sideBAmount,
...(this.meta.extraLabel ? [item.extraAmount ?? null] : []),
this.diff(item),
item.status,
item.remarks
];
const excelRow = sheet.addRow(row);
row.forEach((v, idx) => {
if (typeof v === 'number') {
excelRow.getCell(idx + 1).numFmt = '#,##0.00';
}
});
}
sheet.views = [{ state: 'frozen', ySplit: 4 }];
const so = this.exportSignOff;
const hasSignOff = [
so.preparedByName,
so.checkedByName,
so.approvedByName
].some((v) => v.trim());
if (hasSignOff) {
sheet.addRow([]);
sheet.addRow([]);
const signHead = sheet.addRow([
'Prepared By',
so.preparedByName ? `${so.preparedByName}${so.preparedByDesg ? ' (' + so.preparedByDesg + ')' : ''}` : '—',
'',
'Checked By',
so.checkedByName ? `${so.checkedByName}${so.checkedByDesg ? ' (' + so.checkedByDesg + ')' : ''}` : '—',
'',
'Approved By',
so.approvedByName ? `${so.approvedByName}${so.approvedByDesg ? ' (' + so.approvedByDesg + ')' : ''}` : '—'
]);
signHead.font = { bold: true };
}
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 = `${this.meta.label.replace(/[^\w]+/g, '-')}-${this.scopeName.replace(/[^\w]+/g, '-')}-${this.asOnDate}.xlsx`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.toast(`${this.meta.label} exported (${this.items.length} rows).`);
} catch (err) {
console.error('Reconciliation 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,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 { ReconciliationReportComponent } from './reconciliation-report/reconciliation-report.component';
import { DEFAULT_RECON_ID } from './reconciliation-data';
const routes: Routes = [
Route.withShell([
{
path: 'reconciliation',
data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' },
children: [
{
path: '',
redirectTo: DEFAULT_RECON_ID,
pathMatch: 'full'
},
{
path: ':reportId',
component: ReconciliationReportComponent,
data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' }
}
]
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ReconciliationRoutingModule {}
@@ -0,0 +1,189 @@
/**
* 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/.
*/
// Reconciliation-status colouring layered on top of ../cashier/cashier-shared.scss (pg-*,
// cctr-*), ../investment/investment-shared.scss (inv-kpis, inv-form-*, inv-toast) and
// ../npa/npa-shared.scss (pg-pagination) — reused as-is since they're generic primitives.
.recon-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;
&--matched {
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
}
&--unmatched {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
&--pending {
background: #fefce8;
color: #a16207;
border: 1px solid #fde68a;
}
&--resolved {
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
}
.recon-scope-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
}
.recon-action-row {
display: flex;
align-items: center;
gap: 8px;
}
.recon-remark-input {
height: 32px;
min-width: 160px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 12px;
font-family: inherit;
color: #0d1b2a;
background: #fff;
}
.recon-signoff-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
padding: 16px;
}
.recon-signoff-col {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #f8fafc;
input {
height: 34px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
color: #0d1b2a;
background: #fff;
}
}
.recon-signoff-role {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #0f4f3c;
}
.recon-modal-backdrop {
position: fixed;
inset: 0;
background: rgb(15 23 42 / 55%);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.recon-modal {
width: 100%;
max-width: 820px;
max-height: 90vh;
overflow-y: auto;
background: #fff;
border-radius: 12px;
box-shadow: 0 20px 60px rgb(0 0 0 / 30%);
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
font-size: 15px;
font-weight: 700;
color: #0f172a;
border-bottom: 1px solid #e2e8f0;
}
&__close {
border: none;
background: none;
font-size: 22px;
line-height: 1;
color: #64748b;
cursor: pointer;
padding: 0 4px;
&:hover {
color: #0f172a;
}
}
}
.recon-restricted {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 56px 24px;
text-align: center;
color: #64748b;
&__icon {
font-size: 28px;
color: #94a3b8;
}
&__title {
font-size: 15px;
font-weight: 700;
color: #334155;
}
&__body {
max-width: 440px;
font-size: 13px;
line-height: 1.5;
}
}
@@ -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 { ReconciliationRoutingModule } from './reconciliation-routing.module';
import { ReconciliationReportComponent } from './reconciliation-report/reconciliation-report.component';
@NgModule({
imports: [
SharedModule,
ReconciliationRoutingModule,
ReconciliationReportComponent
]
})
export class ReconciliationModule {}
+16
View File
@@ -43,6 +43,22 @@ const routes: Routes = [
data: { title: 'Sanctions Logs', breadcrumb: 'Sanctions Logs' }, data: { title: 'Sanctions Logs', breadcrumb: 'Sanctions Logs' },
component: SanctionsLogsComponent component: SanctionsLogsComponent
}, },
// Must sit above ':filter', which would otherwise match this path and
// render an empty report category instead.
{
path: 'form-97-98',
data: { title: 'Form 97 / 98 Transaction Report', breadcrumb: 'Form 97 / 98 Transaction Report' },
loadComponent: () =>
import('../form-declarations/form-declaration-report.component').then(
(m) => m.FormDeclarationReportComponent
)
},
{
path: 'risk-review',
data: { title: 'Risk Review Report', breadcrumb: 'Risk Review Report' },
loadComponent: () =>
import('../risk-review/risk-review-report.component').then((m) => m.RiskReviewReportComponent)
},
{ {
path: ':filter', path: ':filter',
data: { routeParamBreadcrumb: 'filter' }, data: { routeParamBreadcrumb: 'filter' },
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,527 @@
<!--
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="rr-root">
<!-- HEADER -->
<div class="rr-head">
<div class="rr-head-left">
<fa-icon icon="user-shield" class="rr-head-ic"></fa-icon>
<div>
<h2 class="rr-head-t">Risk Review Report</h2>
<div class="rr-head-s">
Customers whose transactions tripped an AML rule and whose risk profile therefore needs a look. For each case
the reviewer decides one of four things — keep the rating, move it, call for a KYC update, or refer it to the
Principal Officer for an STR.
</div>
</div>
</div>
<div class="rr-head-actions">
<button class="rr-btn rr-btn--primary" [disabled]="generating || loading" (click)="exportExcel()">
<fa-icon icon="file-excel"></fa-icon> {{ generating ? 'Generating…' : 'Download Excel' }}
</button>
</div>
</div>
@if (sampleCount) {
<div class="rr-banner rr-banner--sample">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>
<strong>{{ sampleCount }} demonstration rows</strong> are included alongside
{{ hasLive ? 'the live alerts' : 'the live feed, which has raised nothing' }}. Sample rows carry a
<span class="rr-tag">Sample</span> tag in the table and must not be acted on as compliance data.
</span>
<button class="rr-btn rr-btn--slim" (click)="load()"><fa-icon icon="undo"></fa-icon> Reload live feed</button>
</div>
}
<!-- FILTERS -->
<div class="rr-filters">
<div class="rr-filter-row">
<div class="rr-modes">
@for (m of periodModes; track m.key) {
<button class="rr-chip" [class.active]="periodMode === m.key" (click)="periodMode = m.key">
{{ m.label }}
</button>
}
</div>
@switch (periodMode) {
@case ('day') {
<label class="rr-field">
<span>Date</span>
<input type="date" [(ngModel)]="filterDay" />
</label>
}
@case ('week') {
<label class="rr-field">
<span>Any date in week</span>
<input type="date" [(ngModel)]="filterWeek" />
</label>
<span class="rr-resolved">Week: {{ rangeLabel }}</span>
}
@case ('month') {
<label class="rr-field">
<span>Month</span>
<input type="month" [(ngModel)]="filterMonth" />
</label>
}
@default {
<label class="rr-field">
<span>Alerts from</span>
<input type="date" [(ngModel)]="filterFrom" />
</label>
<label class="rr-field">
<span>To</span>
<input type="date" [(ngModel)]="filterTo" />
</label>
}
}
</div>
<div class="rr-filter-row">
<label class="rr-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="rr-field">
<span>Current risk</span>
<select [(ngModel)]="currentRisk">
<option value="">Any rating</option>
<option value="LOW">Low</option>
<option value="MEDIUM">Medium</option>
<option value="HIGH">High</option>
</select>
</label>
<label class="rr-field">
<span>AML rule triggered</span>
<select [(ngModel)]="ruleCode">
<option value="">All rules</option>
@for (r of rules; track r.code) {
<option [value]="r.code">{{ r.label }}</option>
}
</select>
</label>
<label class="rr-field">
<span>Review status</span>
<select [(ngModel)]="status">
<option value="">Any status</option>
@for (s of reviewStatuses; track s) {
<option [value]="s">{{ statusMeta[s].label }}</option>
}
</select>
</label>
<label class="rr-field">
<span>Rating outcome</span>
<select [(ngModel)]="direction">
<option value="">Any outcome</option>
<option value="up">Review upward</option>
<option value="down">Review downward</option>
<option value="same">Within current rating</option>
</select>
</label>
<label class="rr-field rr-field--grow">
<span>Search</span>
<input type="text" [(ngModel)]="search" placeholder="Customer name, customer ID, account number or alert ID…" />
</label>
<label class="rr-check">
<input type="checkbox" [(ngModel)]="openOnly" />
Only cases awaiting review
</label>
<button class="rr-btn" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
</div>
@if (!canSelectBranch) {
<div class="rr-scope-note">
<fa-icon icon="lock"></fa-icon>
Showing {{ branchLabel }} only. Head Office logins can select any branch.
</div>
}
</div>
@if (loading) {
<div class="rr-loading"><fa-icon icon="shield-alt"></fa-icon> Loading AML alerts…</div>
} @else {
<!-- SUMMARY -->
<div class="rr-kpis">
<div class="rr-kpi">
<div class="rr-kpi-n">{{ summary.customers }}</div>
<div class="rr-kpi-l">Customers for review</div>
<div class="rr-kpi-sub">
{{ summary.byRisk.LOW }} Low · {{ summary.byRisk.MEDIUM }} Medium · {{ summary.byRisk.HIGH }} High
</div>
</div>
<div class="rr-kpi rr-kpi--blue">
<div class="rr-kpi-n">{{ summary.alerts }}</div>
<div class="rr-kpi-l">AML alerts triggered</div>
<div class="rr-kpi-sub">on {{ summary.txns }} transactions · {{ summary.openAlerts }} awaiting a decision</div>
</div>
<div
class="rr-kpi rr-kpi--amber"
[class.rr-kpi--dim]="summary.upgradeCandidates + summary.downgradeCandidates === 0"
>
<div class="rr-kpi-n">{{ summary.upgradeCandidates + summary.downgradeCandidates }}</div>
<div class="rr-kpi-l">Ratings to be moved</div>
<div class="rr-kpi-sub">
{{ summary.upgradeCandidates }} upward · {{ summary.downgradeCandidates }} downward
</div>
</div>
<div class="rr-kpi" [class.rr-kpi--red]="summary.escalatedCustomers > 0">
<div class="rr-kpi-n">{{ summary.escalatedCustomers }}</div>
<div class="rr-kpi-l">Escalated to STR</div>
<div class="rr-kpi-sub">₹{{ summary.totalAmount | number: '1.0-0' }} under review</div>
</div>
</div>
<!-- RULE MIX — doubles as a one-click filter -->
@if (summary.byRule.length) {
<div class="rr-mix">
<span class="rr-mix-t">Rules triggered</span>
@for (r of summary.byRule; track r.code) {
<button class="rr-mix-item" [class.active]="ruleCode === r.code" (click)="toggleRule(r.code)">
{{ r.label }} <strong>{{ r.count }}</strong>
</button>
}
</div>
}
<!-- VIEW SWITCH -->
<div class="rr-views">
<button class="rr-chip" [class.active]="view === 'customer'" (click)="view = 'customer'">
<fa-icon icon="users"></fa-icon> Customer wise
</button>
<button class="rr-chip" [class.active]="view === 'alert'" (click)="view = 'alert'">
<fa-icon icon="list"></fa-icon> Alert wise
</button>
<button class="rr-chip" [class.active]="view === 'txn'" (click)="view = 'txn'">
<fa-icon icon="filter"></fa-icon> Transaction wise
</button>
<span class="rr-spacer"></span>
<span class="rr-count">
{{
view === 'customer'
? customerRows.length + ' customers'
: view === 'alert'
? alertRows.length + ' alerts'
: txnRows.length + ' transactions'
}}
</span>
</div>
<!-- CUSTOMER WISE -->
@if (view === 'customer') {
<div class="rr-table-wrap">
<table class="rr-table">
<thead>
<tr>
<th></th>
<th>Customer ID</th>
<th>Customer Name</th>
<th>Account Number</th>
<th>Branch</th>
<th>Current Risk</th>
<th class="num">Alerts</th>
<th>AML Rules Triggered</th>
<th class="num">Amount under review</th>
<th>Last Alert</th>
<th>Position</th>
</tr>
</thead>
<tbody>
@for (c of customerRows; track c.clientId) {
<tr
class="rr-cust"
[class.open]="expanded === c.clientId"
[class.rr-row--escalated]="c.escalated"
(click)="toggle(c.clientId)"
>
<td>
<fa-icon [icon]="expanded === c.clientId ? 'caret-down' : 'caret-right'"></fa-icon>
</td>
<td class="mono">{{ c.clientId }}</td>
<td>
<strong>{{ c.customerName }}</strong>
@if (c.openCount > 0) {
<span class="rr-tag">{{ c.openCount }} open</span>
}
</td>
<td class="mono">{{ c.accountNo || '—' }}</td>
<td>{{ c.branchCode || '—' }}</td>
<td>
<span [class]="riskClass(c.currentRisk)">{{ riskText(c.currentRisk) }}</span>
</td>
<td class="num">{{ c.alertCount }}</td>
<td class="rr-rules-cell">
@for (label of c.ruleLabels; track label) {
<span class="rr-rule">{{ label }}</span>
}
</td>
<td class="num strong">{{ c.totalAmount | number: '1.0-0' }}</td>
<td class="mono">{{ c.lastAlertDate }}</td>
<td class="rr-verdict-cell">
<span [class]="verdictClass(c)">{{ verdictText(c) }}</span>
@if (c.directionReason) {
<div class="rr-verdict-why">{{ c.directionReason }}</div>
}
</td>
</tr>
@if (expanded === c.clientId) {
<tr class="rr-detail">
<td colspan="11">
<!-- One block per alert: what the rule is, then the entries it fired on. -->
@for (a of c.alerts; track a.alertId) {
<div class="rr-case">
<div class="rr-case-head">
<span class="rr-rule">{{ a.rule.label }}</span>
<span class="rr-rule-cat">{{ a.rule.category }}</span>
<span class="rr-case-id mono">{{ a.alertId }} · raised {{ a.alertDate }}</span>
<span class="rr-spacer"></span>
<span class="rr-case-amt mono">₹{{ a.amount | number: '1.0-0' }}</span>
<span [class]="statusClass(a.status)">{{ statusMeta[a.status].label }}</span>
</div>
<div class="rr-case-why">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span><strong>Why it triggered:</strong> {{ a.rule.description }} — {{ txnDetail(a) }}</span>
</div>
<table class="rr-subtable">
<thead>
<tr>
<th>Txn Date</th>
<th>Txn ID</th>
<th>Channel</th>
<th>Dr/Cr</th>
<th class="num">Amount</th>
<th class="num">Balance After</th>
<th>Narration</th>
<th>Counterparty</th>
<th>AML Rule Triggered</th>
</tr>
</thead>
<tbody>
@for (t of a.txns; track t.txnId) {
<tr [class.rr-leg--ctr]="t.ctrReportable">
<td class="mono">{{ t.date }}</td>
<td class="mono">{{ t.txnId }}</td>
<td>
<span class="rr-chan">{{ channelLabel(t.channel) }}</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' }}
@if (t.ctrReportable) {
<span class="rr-tag rr-tag--ctr">CTR</span>
}
</td>
<td class="num">{{ t.balanceAfter ? (t.balanceAfter | number: '1.0-0') : '—' }}</td>
<td>{{ t.narration || '—' }}</td>
<td>{{ t.counterparty || '—' }}</td>
<td>
<span class="rr-rule">{{ a.rule.label }}</span>
@if (a.txns.length > 1) {
<span class="rr-leg">leg {{ $index + 1 }} of {{ a.txns.length }}</span>
}
</td>
</tr>
}
</tbody>
</table>
<div class="rr-case-foot">
<div class="rr-suggest">
<fa-icon icon="clipboard-check"></fa-icon> {{ a.rule.suggestedAction }}
</div>
<div class="rr-case-remark">
<strong>Reviewer:</strong> {{ a.reviewedBy || 'not yet assigned' }} —
{{ a.remarks || 'no remarks recorded' }}
</div>
</div>
</div>
}
</td>
</tr>
}
} @empty {
<tr>
<td colspan="11" class="rr-empty">
No customer tripped an AML rule for {{ rangeLabel }} at {{ branchLabel }}.
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- ALERT WISE -->
@if (view === 'alert') {
<div class="rr-table-wrap">
<table class="rr-table">
<thead>
<tr>
<th>Alert ID</th>
<th>Alert Date</th>
<th>Customer ID</th>
<th>Customer Name</th>
<th>Account Number</th>
<th>Branch</th>
<th>Current Risk</th>
<th>Transaction Details</th>
<th>AML Rule Triggered</th>
<th class="num">Amount</th>
<th>Review Status</th>
<th>Reviewer Remarks</th>
</tr>
</thead>
<tbody>
@for (a of alertRows; track a.alertId) {
<tr [class.rr-row--escalated]="a.status === 'ESCALATED_STR' || a.status === 'STR_FILED'">
<td class="mono">{{ a.alertId }}</td>
<td class="mono">{{ a.alertDate }}</td>
<td class="mono">{{ a.clientId }}</td>
<td>
<strong>{{ a.customerName }}</strong>
@if (!a.live) {
<span class="rr-tag">Sample</span>
}
</td>
<td class="mono">{{ a.accountNo || '—' }}</td>
<td>{{ a.branchCode || '—' }}</td>
<td>
<span [class]="riskClass(a.currentRisk)">{{ riskText(a.currentRisk) }}</span>
</td>
<td>{{ txnDetail(a) }}</td>
<td class="rr-rules-cell">
<span class="rr-rule">{{ a.rule.label }}</span>
<span class="rr-rule-cat">{{ a.rule.category }}</span>
<div class="rr-rule-desc">{{ a.rule.description }}</div>
</td>
<td class="num strong">{{ a.amount | number: '1.0-0' }}</td>
<td>
<span [class]="statusClass(a.status)">{{ statusMeta[a.status].label }}</span>
@if (a.reviewedBy) {
<div class="rr-by">by {{ a.reviewedBy }}</div>
}
</td>
<td>
{{ a.remarks || '—' }}
<div class="rr-suggest"><fa-icon icon="clipboard-check"></fa-icon> {{ a.rule.suggestedAction }}</div>
</td>
</tr>
} @empty {
<tr>
<td colspan="12" class="rr-empty">
No AML rule was triggered for {{ rangeLabel }} at {{ branchLabel }}.
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- TRANSACTION WISE — every entry, and the rule it tripped -->
@if (view === 'txn') {
<div class="rr-table-wrap">
<table class="rr-table">
<thead>
<tr>
<th>Txn Date</th>
<th>Txn ID</th>
<th>Customer ID</th>
<th>Customer Name</th>
<th>Account Number</th>
<th>Branch</th>
<th>Current Risk</th>
<th>Channel</th>
<th>Dr/Cr</th>
<th class="num">Amount</th>
<th class="num">Balance After</th>
<th>Narration</th>
<th>Counterparty</th>
<th>AML Rule Triggered</th>
<th>Alert</th>
<th>Review Status</th>
</tr>
</thead>
<tbody>
@for (t of txnRows; track t.txnId) {
<tr [class.rr-row--escalated]="t.status === 'ESCALATED_STR' || t.status === 'STR_FILED'">
<td class="mono">{{ t.date }}</td>
<td class="mono">{{ t.txnId }}</td>
<td class="mono">{{ t.clientId }}</td>
<td>
<strong>{{ t.customerName }}</strong>
</td>
<td class="mono">{{ t.accountNo || '—' }}</td>
<td>{{ t.branchCode || '—' }}</td>
<td>
<span [class]="riskClass(t.currentRisk)">{{ riskText(t.currentRisk) }}</span>
</td>
<td>
<span class="rr-chan">{{ channelLabel(t.channel) }}</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' }}
@if (t.ctrReportable) {
<span class="rr-tag rr-tag--ctr">CTR</span>
}
</td>
<td class="num">{{ t.balanceAfter ? (t.balanceAfter | number: '1.0-0') : '—' }}</td>
<td>{{ t.narration || '—' }}</td>
<td>{{ t.counterparty || '—' }}</td>
<td class="rr-rules-cell">
<span class="rr-rule">{{ t.rule.label }}</span>
<span class="rr-rule-cat">{{ t.rule.category }}</span>
@if (t.legCount > 1) {
<span class="rr-leg">leg {{ t.legIndex }} of {{ t.legCount }}</span>
}
<div class="rr-rule-desc">{{ t.rule.description }}</div>
</td>
<td class="mono">{{ t.alertId }}</td>
<td>
<span [class]="statusClass(t.status)">{{ statusMeta[t.status].label }}</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="16" class="rr-empty">
No transaction tripped an AML rule for {{ rangeLabel }} at {{ branchLabel }}.
</td>
</tr>
}
</tbody>
</table>
</div>
}
<p class="rr-legend">
<span class="rr-status rr-status--open">Pending review</span> awaiting a reviewer ·
<span class="rr-status rr-status--ok">Risk retained</span> explained, rating unchanged ·
<span class="rr-status rr-status--warn">Risk upgraded / KYC update</span> profile changed ·
<span class="rr-status rr-status--danger">Escalated / STR filed</span> with the Principal Officer
</p>
}
<div class="rr-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
</div>
@@ -0,0 +1,820 @@
/**
* 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 {
--r-navy: #1a237e;
--r-blue: #1d4ed8;
--r-blue-l: #eff6ff;
--r-green: #15803d;
--r-green-l: #dcfce7;
--r-amber: #b45309;
--r-amber-l: #fef3c7;
--r-red: #c0392b;
--r-red-l: #fdecea;
--r-violet: #6d28d9;
--r-violet-l: #f3e8ff;
--r-g50: #f8fafc;
--r-g100: #f1f5f9;
--r-g200: #e2e8f0;
--r-g300: #cbd5e1;
--r-g500: #64748b;
--r-g700: #334155;
--r-g900: #0f172a;
display: block;
font-family: 'DM Sans', system-ui, sans-serif;
background: var(--r-g50);
color: var(--r-g900);
font-size: 14px;
line-height: 1.5;
}
.rr-root {
padding: 18px 22px;
max-width: 1800px;
margin: 0 auto;
}
/* HEADER */
.rr-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.rr-head-left {
display: flex;
align-items: flex-start;
gap: 14px;
}
.rr-head-ic {
font-size: 26px;
color: var(--r-navy);
margin-top: 3px;
}
.rr-head-t {
margin: 0;
font-size: 18px;
font-weight: 700;
}
.rr-head-s {
font-size: 12px;
color: var(--r-g500);
max-width: 900px;
}
/* BUTTONS */
.rr-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 15px;
border: 1px solid var(--r-g300);
background: #fff;
border-radius: 8px;
font-size: 12.5px;
font-weight: 600;
color: var(--r-g700);
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
&:hover:not(:disabled) {
background: var(--r-g50);
border-color: var(--r-g500);
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
&--primary {
background: var(--r-green);
border-color: var(--r-green);
color: #fff;
&:hover:not(:disabled) {
background: #12692f;
border-color: #12692f;
color: #fff;
}
}
&--slim {
padding: 5px 11px;
font-size: 11.5px;
white-space: nowrap;
}
}
.rr-head-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
/* BANNER */
.rr-banner {
display: flex;
align-items: center;
gap: 11px;
border-radius: 10px;
padding: 10px 14px;
font-size: 12.5px;
margin-bottom: 14px;
span {
flex: 1;
}
&--sample {
background: var(--r-amber-l);
border: 1px solid #fcd34d;
color: #78350f;
}
}
/* FILTERS */
.rr-filters {
background: #fff;
border: 1px solid var(--r-g200);
border-radius: 11px;
padding: 12px 16px;
margin-bottom: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.rr-filter-row {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
}
.rr-modes {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.rr-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: 1px solid var(--r-g300);
background: #fff;
border-radius: 999px;
font-size: 12.5px;
font-weight: 600;
color: var(--r-g700);
cursor: pointer;
font-family: inherit;
&:hover {
border-color: var(--r-navy);
}
&.active {
background: var(--r-navy);
border-color: var(--r-navy);
color: #fff;
}
}
.rr-field {
display: flex;
flex-direction: column;
gap: 3px;
span {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--r-g500);
}
input,
select {
padding: 7px 10px;
border: 1px solid var(--r-g300);
border-radius: 7px;
font-size: 13px;
font-family: inherit;
background: #fff;
color: var(--r-g900);
&:focus {
outline: none;
border-color: var(--r-navy);
}
&:disabled {
background: var(--r-g100);
color: var(--r-g500);
}
}
&--grow {
flex: 1;
min-width: 260px;
input {
width: 100%;
}
}
}
.rr-resolved {
font-size: 12px;
color: var(--r-g500);
padding-bottom: 8px;
}
.rr-check {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
font-weight: 600;
color: var(--r-g700);
padding-bottom: 8px;
cursor: pointer;
}
.rr-scope-note {
display: flex;
align-items: center;
gap: 7px;
font-size: 12px;
color: var(--r-amber);
background: var(--r-amber-l);
border: 1px solid #fcd34d;
border-radius: 7px;
padding: 6px 11px;
}
/* LOADING */
.rr-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
padding: 60px;
color: var(--r-g500);
font-size: 13px;
background: #fff;
border: 1px solid var(--r-g200);
border-radius: 11px;
}
/* KPIs */
.rr-kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 14px;
}
.rr-kpi {
background: #fff;
border: 1px solid var(--r-g200);
border-left: 4px solid var(--r-navy);
border-radius: 11px;
padding: 12px 16px;
&--blue {
border-left-color: var(--r-blue);
}
&--amber {
border-left-color: var(--r-amber);
}
&--red {
border-left-color: var(--r-red);
}
&--dim {
border-left-color: var(--r-g300);
}
}
.rr-kpi-n {
font-size: 22px;
font-weight: 700;
font-family: 'DM Mono', monospace;
}
.rr-kpi-l {
font-size: 12px;
font-weight: 600;
color: var(--r-g700);
}
.rr-kpi-sub {
font-size: 11px;
color: var(--r-g500);
}
/* RULE MIX */
.rr-mix {
display: flex;
align-items: center;
gap: 7px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.rr-mix-t {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--r-g500);
margin-right: 2px;
}
.rr-mix-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 11px;
border: 1px solid var(--r-g200);
background: #fff;
border-radius: 7px;
font-size: 11.5px;
font-weight: 600;
color: var(--r-g700);
cursor: pointer;
font-family: inherit;
strong {
font-family: 'DM Mono', monospace;
color: var(--r-navy);
}
&:hover {
border-color: var(--r-navy);
}
&.active {
background: var(--r-navy);
border-color: var(--r-navy);
color: #fff;
strong {
color: #fff;
}
}
}
/* VIEW SWITCH */
.rr-views {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.rr-spacer {
flex: 1;
}
.rr-count {
font-size: 12px;
color: var(--r-g500);
font-weight: 600;
}
/* TABLE */
.rr-table-wrap {
background: #fff;
border: 1px solid var(--r-g200);
border-radius: 11px;
overflow-x: auto;
}
.rr-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
th {
text-align: left;
padding: 9px 10px;
background: var(--r-g100);
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--r-g700);
white-space: nowrap;
border-bottom: 1px solid var(--r-g200);
}
td {
padding: 8px 10px;
border-bottom: 1px solid var(--r-g100);
vertical-align: top;
}
.num {
text-align: right;
font-family: 'DM Mono', monospace;
}
.strong {
font-weight: 700;
}
tbody tr:hover:not(.rr-detail) {
background: var(--r-g50);
}
}
.rr-cust {
cursor: pointer;
&.open {
background: var(--r-blue-l);
}
}
.rr-row--escalated {
background: var(--r-red-l);
}
.rr-detail {
background: #fcfcfe;
td {
padding: 12px 16px 16px;
}
}
.rr-subtable {
width: 100%;
border-collapse: collapse;
font-size: 12px;
background: #fff;
border: 1px solid var(--r-g200);
border-radius: 8px;
overflow: hidden;
th {
text-align: left;
padding: 7px 10px;
background: var(--r-g50);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--r-g500);
white-space: nowrap;
}
td {
padding: 7px 10px;
border-top: 1px solid var(--r-g100);
vertical-align: top;
}
.num {
text-align: right;
font-family: 'DM Mono', monospace;
}
.strong {
font-weight: 700;
}
}
/* One alert inside an expanded customer: rule, why, entries, outcome. */
.rr-case {
background: #fff;
border: 1px solid var(--r-g200);
border-radius: 9px;
padding: 10px 12px;
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
}
.rr-case-head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 6px;
}
.rr-case-id {
font-size: 11px;
color: var(--r-g500);
}
.rr-case-amt {
font-size: 13px;
font-weight: 700;
}
.rr-case-why {
display: flex;
align-items: baseline;
gap: 7px;
font-size: 11.5px;
color: #78350f;
background: var(--r-amber-l);
border-radius: 6px;
padding: 5px 10px;
margin-bottom: 8px;
}
.rr-case-foot {
display: flex;
align-items: baseline;
gap: 18px;
flex-wrap: wrap;
margin-top: 7px;
padding-top: 7px;
border-top: 1px dashed var(--r-g200);
}
.rr-case-remark {
font-size: 11.5px;
color: var(--r-g700);
}
/* Channel of a single entry */
.rr-chan {
display: inline-block;
padding: 1px 7px;
border-radius: 5px;
font-size: 10.5px;
font-weight: 700;
background: var(--r-g100);
color: var(--r-g700);
white-space: nowrap;
}
/* Position of an entry within a multi-leg pattern alert */
.rr-leg {
display: inline-block;
margin-left: 5px;
padding: 1px 7px;
border-radius: 5px;
font-size: 10px;
font-weight: 700;
background: var(--r-blue-l);
color: var(--r-blue);
white-space: nowrap;
}
.rr-leg--ctr {
background: var(--r-red-l);
}
.cr {
color: var(--r-green);
font-weight: 700;
}
.dr {
color: var(--r-red);
font-weight: 700;
}
/* Rule cell */
.rr-rules-cell {
min-width: 230px;
max-width: 340px;
}
.rr-rule {
display: inline-block;
margin: 0 5px 3px 0;
padding: 1px 8px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
background: var(--r-violet-l);
color: var(--r-violet);
}
.rr-rule-cat {
display: inline-block;
padding: 1px 7px;
border-radius: 5px;
font-size: 10px;
font-weight: 700;
background: var(--r-g100);
color: var(--r-g700);
}
.rr-rule-desc {
font-size: 11px;
color: var(--r-g500);
margin-top: 2px;
}
.rr-suggest {
font-size: 11px;
color: var(--r-blue);
margin-top: 3px;
display: flex;
align-items: baseline;
gap: 5px;
}
.rr-by {
font-size: 10.5px;
color: var(--r-g500);
margin-top: 2px;
}
/* Risk rating badge */
.rr-risk {
display: inline-block;
padding: 1px 9px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
white-space: nowrap;
&--low {
background: var(--r-green-l);
color: var(--r-green);
}
&--medium {
background: var(--r-amber-l);
color: var(--r-amber);
}
&--high {
background: var(--r-red-l);
color: var(--r-red);
}
&--none {
background: var(--r-g100);
color: var(--r-g500);
}
}
/* Review status badge */
.rr-status {
display: inline-block;
padding: 1px 9px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
white-space: nowrap;
&--open {
background: var(--r-g100);
color: var(--r-g700);
}
&--progress {
background: var(--r-blue-l);
color: var(--r-blue);
}
&--ok {
background: var(--r-green-l);
color: var(--r-green);
}
&--warn {
background: var(--r-amber-l);
color: var(--r-amber);
}
&--danger {
background: var(--r-red-l);
color: var(--r-red);
}
}
/* What the report says should happen to the rating */
.rr-verdict-cell {
min-width: 210px;
max-width: 300px;
}
.rr-verdict {
display: inline-block;
font-size: 11px;
font-weight: 600;
color: var(--r-g500);
white-space: nowrap;
&--up {
color: var(--r-amber);
}
/* A rating coming down is good news, not a warning. */
&--down {
color: var(--r-green);
}
&--str {
color: var(--r-red);
font-weight: 700;
}
}
.rr-verdict-why {
font-size: 10.5px;
color: var(--r-g500);
margin-top: 2px;
white-space: normal;
}
.rr-tag {
display: inline-block;
margin-left: 6px;
padding: 1px 7px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
background: var(--r-g100);
color: var(--r-g700);
white-space: nowrap;
&--ctr {
background: var(--r-red-l);
color: var(--r-red);
}
}
.mono {
font-family: 'DM Mono', monospace;
font-size: 11.5px;
}
.rr-empty {
padding: 44px;
text-align: center;
color: var(--r-g500);
font-size: 13px;
}
.rr-legend {
font-size: 11.5px;
color: var(--r-g500);
margin: 10px 2px 0;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
/* TOAST */
.rr-toast {
position: fixed;
bottom: 32px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--r-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) {
.rr-kpis {
grid-template-columns: 1fr 1fr;
}
}
@@ -0,0 +1,726 @@
/**
* 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, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { forkJoin, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
/** Custom Modules */
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
/** Custom Services */
import { AmlService } from '../aml/aml.service';
import { ClientsService } from '../clients/clients.service';
import { BranchScopeService } from '../core/branch-scope.service';
/** Shared report helpers */
import { exportWorkbook } from '../cashier/cashier-export';
import { GL_BRANCHES, GL_PERIOD, glBranchName } from '../gl-reports/gl-reports-data';
/** Report data + computation */
import {
AML_RULE_CATALOGUE,
LiveAmlAlert,
REVIEW_STATUSES,
REVIEW_STATUS_META,
RISK_LABEL,
ReviewStatus,
RiskAlertRow,
RiskLevel,
channelLabel,
mapLiveAlert,
riskFromCategory,
sampleRiskAlerts
} from './risk-review-data';
import {
DateRange,
RatingDirection,
RiskAlertView,
RiskCustomerRow,
RiskReviewFilter,
RiskReviewModel,
RiskReviewSummary,
RiskTxnView,
buildRiskReview,
monthRange,
weekRange
} from './risk-review.compute';
type PeriodMode = 'day' | 'week' | 'month' | 'range';
type ViewMode = 'customer' | 'alert' | 'txn';
/** Starting value for the memo, used before the first filter pass runs. */
const EMPTY_SUMMARY: RiskReviewSummary = {
customers: 0,
alerts: 0,
txns: 0,
ctrReportable: 0,
openAlerts: 0,
escalatedCustomers: 0,
upgradeCandidates: 0,
downgradeCandidates: 0,
totalAmount: 0,
byRisk: { LOW: 0, MEDIUM: 0, HIGH: 0, UNRATED: 0 },
byRule: [],
byStatus: []
};
/**
* Risk Review Report — the AML/compliance worklist.
*
* Lists the customers who tripped an AML rule in the period, the transaction
* that tripped it, the rating currently on their KYC record and where the
* review has got to. The reviewer's decision on each case is one of: keep the
* rating, move it, refresh KYC, or escalate to the Principal Officer for an
* STR — which is the vocabulary in `REVIEW_STATUS_META`.
*
* Rows come from the live rule engine. If the engine has raised nothing (or is
* unreachable) the report falls back to a deterministic sample set and says so
* on screen and in the workbook, so sample rows are never taken for live data.
*/
@Component({
selector: 'mifosx-risk-review-report',
templateUrl: './risk-review-report.component.html',
styleUrls: ['./risk-review-report.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
FormsModule
]
})
export class RiskReviewReportComponent implements OnInit {
private aml = inject(AmlService);
private clients = inject(ClientsService);
private scope = inject(BranchScopeService);
readonly period = GL_PERIOD;
readonly branches = GL_BRANCHES;
readonly rules = AML_RULE_CATALOGUE;
readonly reviewStatuses = REVIEW_STATUSES;
readonly statusMeta = REVIEW_STATUS_META;
readonly riskLabel = RISK_LABEL;
readonly channelLabel = channelLabel;
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' }
];
// ── Source state ───────────────────────────────────────────────────────────
/** Rows returned by the monitoring engine, mapped onto the report shape. */
private liveAlerts: RiskAlertRow[] = [];
loading = true;
/**
* Live alerts and the demonstration set, in one worklist.
*
* Every row keeps its `live` flag and sample rows are tagged in the table, so
* a reviewer can still tell them apart at a glance.
*/
private alerts: RiskAlertRow[] = [];
get hasLive(): boolean {
return this.liveAlerts.length > 0;
}
get sampleCount(): number {
return this.alerts.filter((a) => !a.live).length;
}
// ── Filter state ───────────────────────────────────────────────────────────
/**
* Alerts raised today must be in view, so the default window runs to whichever
* is later — the seeded period end or the real current date.
*/
private static readonly TODAY = new Date().toISOString().slice(0, 10);
private static readonly WINDOW_END =
RiskReviewReportComponent.TODAY > GL_PERIOD.fyEnd ? RiskReviewReportComponent.TODAY : GL_PERIOD.fyEnd;
periodMode: PeriodMode = 'range';
filterDay = RiskReviewReportComponent.WINDOW_END;
/** Any date inside the wanted week; resolved to Mon–Sat. */
filterWeek = '2026-07-27';
filterMonth = '2026-07';
filterFrom = GL_PERIOD.fyStart;
filterTo = RiskReviewReportComponent.WINDOW_END;
branchCode = '';
currentRisk: RiskLevel | '' = '';
ruleCode = '';
status: ReviewStatus | '' = '';
/** Which way the review says the rating should move. '' shows every customer. */
direction: RatingDirection | '' = '';
search = '';
openOnly = false;
view: ViewMode = 'customer';
/** Client ID of the customer row currently expanded. */
expanded: number | 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;
this.load();
}
// ── Loading ────────────────────────────────────────────────────────────────
load(): void {
this.loading = true;
this.aml
.getAlerts()
.pipe(catchError(() => of([] as LiveAmlAlert[])))
.subscribe((live) => {
const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert));
this.liveAlerts = rows;
this.compose();
if (!rows.length) {
this.loading = false;
return;
}
this.enrichCurrentRisk(rows);
});
}
/** Live alerts first, then the demonstration set behind them. */
private compose(): void {
this.alerts = [
...this.liveAlerts,
...sampleRiskAlerts()
];
this.memoKey = '';
}
/**
* The alert payload carries no rating, so the customer's current risk is read
* off their KYC record — one call per distinct customer, not per alert. A
* client whose KYC cannot be read stays unrated rather than blocking the
* report, so the table renders either way.
*/
private enrichCurrentRisk(rows: RiskAlertRow[]): void {
const clientIds = [...new Set(rows.map((r) => r.clientId))].filter((id) => id != null);
if (!clientIds.length) {
this.loading = false;
return;
}
forkJoin(
clientIds.map((id) =>
this.clients.getClientKyc(String(id), true).pipe(
map((kyc: any) => ({ id, risk: riskFromCategory(kyc?.riskCategory) })),
catchError(() => of({ id, risk: null as RiskLevel | null }))
)
)
).subscribe((results) => {
const byClient = new Map(
results.map((r) => [
r.id,
r.risk
])
);
this.liveAlerts = rows.map((r) => ({ ...r, currentRisk: byClient.get(r.clientId) ?? null }));
this.compose();
this.loading = false;
});
}
// ── 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 {
return this.scope.scopeLabel(this.branchCode);
}
/**
* 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(): RiskReviewFilter {
return {
range: this.range,
branchCode: this.effectiveBranch,
currentRisk: this.currentRisk,
ruleCode: this.ruleCode,
status: this.status,
direction: this.direction,
search: this.search,
openOnly: this.openOnly
};
}
resetFilters(): void {
this.periodMode = 'range';
this.filterDay = RiskReviewReportComponent.WINDOW_END;
this.filterWeek = '2026-07-27';
this.filterMonth = '2026-07';
this.filterFrom = GL_PERIOD.fyStart;
this.filterTo = RiskReviewReportComponent.WINDOW_END;
this.currentRisk = '';
this.ruleCode = '';
this.status = '';
this.direction = '';
this.search = '';
this.openOnly = false;
this.expanded = null;
// Reset returns a branch login to its own branch, never to "all".
this.branchCode = this.scope.defaultBranchCode;
}
// ── Data ───────────────────────────────────────────────────────────────────
/**
* The whole screen reads from one computed model. Change detection hits these
* getters many times per cycle, so the result is memoised against the filter
* rather than re-filtering thousands of rows on every binding.
*/
private memoKey = '';
private memo: RiskReviewModel = { alerts: [], customers: [], txns: [], summary: EMPTY_SUMMARY };
private get model(): RiskReviewModel {
const key = JSON.stringify(this.filter) + `|${this.alerts.length}`;
if (key !== this.memoKey) {
this.memo = buildRiskReview(this.alerts, this.filter);
this.memoKey = key;
}
return this.memo;
}
get alertRows(): RiskAlertView[] {
return this.model.alerts;
}
get customerRows(): RiskCustomerRow[] {
return this.model.customers;
}
get txnRows(): RiskTxnView[] {
return this.model.txns;
}
get summary(): RiskReviewSummary {
return this.model.summary;
}
toggle(clientId: number): void {
this.expanded = this.expanded === clientId ? null : clientId;
}
/** One-click drill from the rule mix strip into that rule. */
toggleRule(code: string): void {
this.ruleCode = this.ruleCode === code ? '' : code;
this.expanded = null;
}
// ── Presentation helpers ───────────────────────────────────────────────────
riskClass(level: RiskLevel | null): string {
return level ? `rr-risk rr-risk--${level.toLowerCase()}` : 'rr-risk rr-risk--none';
}
riskText(level: RiskLevel | null): string {
return level ? RISK_LABEL[level] : 'Unrated';
}
statusClass(status: ReviewStatus): string {
return `rr-status rr-status--${REVIEW_STATUS_META[status].tone}`;
}
/** What the review concludes should happen to the rating, in one phrase. */
verdictText(c: RiskCustomerRow): string {
if (c.escalated) {
return 'Escalated to STR';
}
if (c.direction === 'up') {
return `Review upward → ${RISK_LABEL[c.recommendedRisk]}`;
}
if (c.direction === 'down') {
return `Review downward → ${RISK_LABEL[c.recommendedRisk]}`;
}
return 'Within current rating';
}
verdictClass(c: RiskCustomerRow): string {
if (c.escalated) {
return 'rr-verdict rr-verdict--str';
}
if (c.direction === 'up') {
return 'rr-verdict rr-verdict--up';
}
if (c.direction === 'down') {
return 'rr-verdict rr-verdict--down';
}
return 'rr-verdict';
}
/** Transaction detail as the reviewer would read it in one line. */
txnDetail(a: RiskAlertView): string {
const amount = `₹${a.amount.toLocaleString('en-IN')}`;
if (a.txnCount > 1) {
return `${a.txnCount} × ${channelLabel(a.channel)} aggregating ${amount}`;
}
return `${channelLabel(a.channel)} of ${amount}`;
}
// ── Excel ──────────────────────────────────────────────────────────────────
async exportExcel(): Promise<void> {
this.generating = true;
try {
const s = this.summary;
const subtitle =
`Period: ${this.rangeLabel} · Branch: ${this.branchLabel} · ` +
`Source: live AML monitoring engine` +
(this.sampleCount ? ` + ${this.sampleCount} DEMONSTRATION rows (not compliance data)` : '');
await exportWorkbook(
[
{
sheetName: 'Summary',
title: 'Risk Review Report — Summary',
subtitle,
headers: [
'Particulars',
'Value'
],
widths: [
56,
22
],
rows: [
[
'Customers flagged for risk review',
String(s.customers)
],
[
'AML alerts triggered',
String(s.alerts)
],
[
'Transactions behind those alerts',
String(s.txns)
],
[
' of which cash entries at or above the ₹10,00,000 CTR limit',
String(s.ctrReportable)
],
[
'Alerts still awaiting a reviewer decision',
String(s.openAlerts)
],
[
'Customers rated below what the triggered rules point at (review upward)',
String(s.upgradeCandidates)
],
[
'Customers rated above what the period’s activity warrants (review downward)',
String(s.downgradeCandidates)
],
[
'Customers escalated to the Principal Officer (STR)',
String(s.escalatedCustomers)
],
[
'Total transaction value under review',
s.totalAmount
],
[
'',
''
],
[
'Customers currently rated Low',
String(s.byRisk.LOW)
],
[
'Customers currently rated Medium',
String(s.byRisk.MEDIUM)
],
[
'Customers currently rated High',
String(s.byRisk.HIGH)
],
[
'Customers with no rating on record',
String(s.byRisk.UNRATED)
]
]
},
{
sheetName: 'Rules triggered',
title: 'Risk Review Report — AML Rules Triggered',
subtitle,
headers: [
'AML Rule Triggered',
'Category',
'Alerts',
'What the rule looks for',
'Action expected of the reviewer'
],
widths: [
34,
14,
10,
62,
62
],
rows: s.byRule.map((r) => {
const meta = AML_RULE_CATALOGUE.find((m) => m.code === r.code);
return [
r.label,
meta?.category ?? '—',
r.count,
meta?.description ?? '—',
meta?.suggestedAction ?? '—'
];
})
},
{
sheetName: 'Customer wise',
title: 'Risk Review Report — Customer wise',
subtitle,
headers: [
'Customer ID',
'Customer Name',
'Account Number',
'Branch',
'Current Risk',
'Alerts',
'AML Rules Triggered',
'Total Amount',
'Largest Alert',
'First Alert',
'Last Alert',
'Rules Point At',
'Recommended Risk',
'Open Cases',
'Position',
'Basis'
],
widths: [
13,
28,
20,
16,
13,
8,
46,
16,
16,
12,
12,
14,
16,
11,
24,
70
],
rows: this.customerRows.map((c) => [
String(c.clientId),
c.customerName,
c.accountNo || '—',
c.branchCode ? glBranchName(c.branchCode) : '—',
this.riskText(c.currentRisk),
c.alertCount,
c.ruleLabels.join('; '),
c.totalAmount,
c.largestAmount,
c.firstAlertDate,
c.lastAlertDate,
RISK_LABEL[c.proposedRisk],
RISK_LABEL[c.recommendedRisk],
c.openCount,
this.verdictText(c),
c.directionReason || '—'
])
},
{
sheetName: 'Alert wise',
title: 'Risk Review Report — Alert wise',
subtitle,
headers: [
'Alert ID',
'Alert Date',
'Customer ID',
'Customer Name',
'Account Number',
'Branch',
'Current Risk',
'Transaction Details',
'AML Rule Triggered',
'Rule Category',
'Amount',
'Review Status',
'Reviewed By',
'Reviewer Remarks',
'Action Expected'
],
widths: [
11,
12,
13,
28,
20,
16,
13,
38,
32,
14,
16,
22,
16,
60,
60
],
rows: this.alertRows.map((a) => [
a.alertId,
a.alertDate,
String(a.clientId),
a.customerName,
a.accountNo || '—',
a.branchCode ? glBranchName(a.branchCode) : '—',
this.riskText(a.currentRisk),
this.txnDetail(a),
a.rule.label,
a.rule.category,
a.amount,
REVIEW_STATUS_META[a.status].label,
a.reviewedBy || '—',
a.remarks || '—',
a.rule.suggestedAction
])
},
{
sheetName: 'Transaction wise',
title: 'Risk Review Report — Transaction wise (rule triggered by each entry)',
subtitle,
headers: [
'Txn Date',
'Txn ID',
'Customer ID',
'Customer Name',
'Account Number',
'Branch',
'Current Risk',
'Channel',
'Dr/Cr',
'Amount',
'Balance After',
'Narration',
'Counterparty',
'AML Rule Triggered',
'Rule Category',
'Leg',
'Alert ID',
'Alert Date',
'Review Status',
'CTR'
],
widths: [
12,
12,
13,
28,
20,
16,
13,
18,
7,
16,
16,
32,
38,
32,
14,
9,
11,
12,
22,
7
],
rows: this.txnRows.map((t) => [
t.date,
t.txnId,
String(t.clientId),
t.customerName,
t.accountNo || '—',
t.branchCode ? glBranchName(t.branchCode) : '—',
this.riskText(t.currentRisk),
channelLabel(t.channel),
t.drCr,
t.amount,
t.balanceAfter || null,
t.narration || '—',
t.counterparty || '—',
t.rule.label,
t.rule.category,
t.legCount > 1 ? `${t.legIndex} of ${t.legCount}` : '—',
t.alertId,
t.alertDate,
REVIEW_STATUS_META[t.status].label,
t.ctrReportable ? 'Yes' : ''
])
}
],
`Risk-Review-Report-${this.range.from}-to-${this.range.to}`
);
this.toast(
`Exported ${s.txns} transactions across ${s.alerts} alerts and ${s.customers} customers for ${this.rangeLabel}`
);
} catch {
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);
}
}
+489
View File
@@ -0,0 +1,489 @@
/**
* 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 {
AmlRuleMeta,
CTR_THRESHOLD,
MATERIALITY,
NON_MITIGABLE_RULES,
REVIEW_STATUS_META,
RISK_LABEL,
RiskAlertRow,
RiskLevel,
RiskTxn,
ReviewStatus,
amlRuleMeta,
riskRank
} from './risk-review-data';
/** Which way the review says the rating should move. */
export type RatingDirection = 'up' | 'down' | 'same';
const RANK_TO_LEVEL: Record<number, RiskLevel> = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' };
export interface DateRange {
from: string;
to: string;
}
export interface RiskReviewFilter {
range: DateRange;
/** '' means every branch. */
branchCode: string;
/** '' means every rating. */
currentRisk: RiskLevel | '';
/** '' means every rule. */
ruleCode: string;
/** '' means every review status. */
status: ReviewStatus | '';
/** '' means every customer, whichever way their rating should move. */
direction: RatingDirection | '';
/** Free text over customer name, customer ID, account number and alert ID. */
search: string;
/** Only cases a reviewer still has to act on. */
openOnly: boolean;
}
/** An alert joined to its rule catalogue entry, ready to render. */
export interface RiskAlertView extends RiskAlertRow {
rule: AmlRuleMeta;
}
/**
* One transaction carrying the alert and rule it belongs to.
*
* This is the row that answers "which AML rule did this transaction trip" —
* the alert is flattened onto each of its entries so the same transaction can
* be read on its own, without expanding a customer first.
*/
export interface RiskTxnView extends RiskTxn {
alertId: string;
alertDate: string;
clientId: number;
customerName: string;
accountNo: string;
branchCode: string;
currentRisk: RiskLevel | null;
status: ReviewStatus;
remarks: string;
rule: AmlRuleMeta;
/** Position of this entry within its alert, e.g. "2 of 7" for a series. */
legIndex: number;
legCount: number;
}
/** All of one customer's alerts in the period, rolled up. */
export interface RiskCustomerRow {
clientId: number;
customerName: string;
accountNo: string;
branchCode: string;
currentRisk: RiskLevel | null;
alerts: RiskAlertView[];
alertCount: number;
/** Sum of the amounts behind the alerts — the exposure under review. */
totalAmount: number;
largestAmount: number;
/** Highest risk any of the triggered rules points at, before materiality. */
proposedRisk: RiskLevel;
/** What the review concludes the rating should be, after materiality. */
recommendedRisk: RiskLevel;
/** Whether that is above, below or the same as the rating on record. */
direction: RatingDirection;
/** One line explaining the recommendation, shown under the verdict. */
directionReason: string;
/** Distinct rule labels, for the "AML Rule Triggered" cell. */
ruleLabels: string[];
/** Earliest and latest alert in the period. */
firstAlertDate: string;
lastAlertDate: string;
openCount: number;
/** True when any alert on the customer has gone to the Principal Officer. */
escalated: boolean;
/** True when the rules point above the rating currently on the KYC record. */
needsUpgrade: boolean;
/** True when the rating on record is higher than the activity warrants. */
needsDowngrade: boolean;
}
export interface RiskReviewSummary {
customers: number;
alerts: number;
/** Individual entries behind those alerts. */
txns: number;
/** Cash entries at or above the CTR limit on their own. */
ctrReportable: number;
openAlerts: number;
escalatedCustomers: number;
upgradeCandidates: number;
downgradeCandidates: number;
totalAmount: number;
byRisk: Record<'LOW' | 'MEDIUM' | 'HIGH' | 'UNRATED', number>;
/** Rule mix across the filtered set, most-triggered first. */
byRule: { code: string; label: string; count: number }[];
byStatus: { status: ReviewStatus; label: string; count: number }[];
}
function inRange(date: string, range: DateRange): boolean {
return date >= range.from && date <= range.to;
}
function inr(n: number): string {
return `₹${Math.round(n).toLocaleString('en-IN')}`;
}
/**
* Works out what the rating ought to be once the size of the activity is taken
* into account, and why.
*
* The rules give an upper bound — what the behaviour could mean at worst. The
* amounts then pull that bound down: a customer sitting at High who has only put
* a handful of small transfers through has not behaved like a High-risk customer
* this period, and the review should say so. Two things stop the pull-down: a
* case already referred for an STR, and rules whose seriousness has nothing to
* do with value — a sanctions match or a structuring pattern stays grave at any
* amount.
*/
function recommendRating(
impliedRank: number,
currentRisk: RiskLevel | null,
largestAmount: number,
totalAmount: number,
escalated: boolean,
ruleCodes: string[]
): { risk: RiskLevel; reason: string } {
const implied = RANK_TO_LEVEL[impliedRank] ?? 'LOW';
const grave = ruleCodes.find((c) => NON_MITIGABLE_RULES.has(c));
const material = largestAmount >= CTR_THRESHOLD || totalAmount >= MATERIALITY.highTotal;
const small = largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal;
// What the rating should be. Amounts only ever pull the rules' bound down, and
// only where nothing weightier holds it up.
let risk: RiskLevel;
let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate';
if (escalated) {
risk = 'HIGH';
basis = 'escalated';
} else if (grave) {
risk = implied;
basis = 'grave';
} else if (material) {
risk = implied;
basis = 'material';
} else {
risk = RANK_TO_LEVEL[Math.min(impliedRank, small ? 1 : 2)] ?? 'LOW';
basis = small ? 'small' : 'moderate';
}
// The wording is worked out from the conclusion, not the branch, so a customer
// being brought down is never told the activity supports where they are.
const sizes = `largest entry ${inr(largestAmount)}, ${inr(totalAmount)} in total`;
const rated = currentRisk ? `Rated ${RISK_LABEL[currentRisk]} on record` : 'No rating on record';
const delta = currentRisk ? riskRank(risk) - riskRank(currentRisk) : 0;
if (basis === 'escalated') {
return { risk, reason: 'Case referred to the Principal Officer — rating held at High pending the outcome.' };
}
if (delta < 0) {
if (basis === 'small') {
return { risk, reason: `Only small-value activity — ${sizes}. ${rated}; not supported by this period.` };
}
if (basis === 'moderate') {
return { risk, reason: `Moderate activity — ${sizes}. ${rated}; one step down is proportionate.` };
}
// Material or grave, yet still below where the customer sits: the size of
// the activity is not the reason, the rules that fired are.
return {
risk,
reason:
`Activity of ${inr(totalAmount)} is material, but the rules triggered point only at ${RISK_LABEL[risk]}. ` +
`${rated}.`
};
}
if (delta > 0) {
return { risk, reason: `Triggered rules point at ${RISK_LABEL[risk]}, above the rating on record.` };
}
if (basis === 'grave') {
return {
risk,
reason: `${amlRuleMeta(grave as string).label} carries its own weight — the rating does not come down on amount alone.`
};
}
return { risk, reason: '' };
}
/** Applies every filter and joins each alert to its rule. */
export function riskAlertViews(alerts: RiskAlertRow[], filter: RiskReviewFilter): RiskAlertView[] {
const needle = filter.search.trim().toLowerCase();
return alerts
.filter((a) => {
if (!inRange(a.alertDate, filter.range)) {
return false;
}
// A live alert carries no branch; hiding it under a branch filter would
// silently drop real compliance data, so it stays visible.
if (filter.branchCode && a.branchCode && a.branchCode !== filter.branchCode) {
return false;
}
if (filter.currentRisk && a.currentRisk !== filter.currentRisk) {
return false;
}
if (filter.ruleCode && a.ruleCode !== filter.ruleCode) {
return false;
}
if (filter.status && a.status !== filter.status) {
return false;
}
if (filter.openOnly && !REVIEW_STATUS_META[a.status].open) {
return false;
}
if (needle) {
const hay = `${a.customerName} ${a.clientId} ${a.accountNo} ${a.alertId} ${a.ruleCode}`.toLowerCase();
if (!hay.includes(needle)) {
return false;
}
}
return true;
})
.map((a) => ({ ...a, rule: amlRuleMeta(a.ruleCode, a.ruleDescription) }))
.sort((a, b) => b.alertDate.localeCompare(a.alertDate) || a.customerName.localeCompare(b.customerName));
}
/**
* Rolls the alerts up per customer. Sorted so the cases needing attention —
* escalated, then rated below what the rules point at, then most alerts —
* come to the top of the worklist.
*/
export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] {
const byClient = new Map<number, RiskAlertView[]>();
for (const v of views) {
const list = byClient.get(v.clientId);
if (list) {
list.push(v);
} else {
byClient.set(v.clientId, [v]);
}
}
const rows: RiskCustomerRow[] = [];
for (const [
clientId,
alerts
] of byClient) {
const dates = alerts.map((a) => a.alertDate).sort();
const proposedRank = Math.max(...alerts.map((a) => riskRank(a.rule.impliedRisk)));
const proposedRisk: RiskLevel = RANK_TO_LEVEL[proposedRank] ?? 'LOW';
const head = alerts[0];
const currentRisk = alerts.find((a) => a.currentRisk)?.currentRisk ?? null;
const totalAmount = alerts.reduce((s, a) => s + a.amount, 0);
const largestAmount = Math.max(...alerts.map((a) => a.amount));
const escalated = alerts.some((a) => a.status === 'ESCALATED_STR' || a.status === 'STR_FILED');
const recommendation = recommendRating(
proposedRank,
currentRisk,
largestAmount,
totalAmount,
escalated,
alerts.map((a) => a.ruleCode)
);
// Where the customer carries no rating there is nothing to move, so an
// unrated customer is never counted as an upgrade or a downgrade.
const delta = currentRisk ? riskRank(recommendation.risk) - riskRank(currentRisk) : 0;
const direction: RatingDirection = delta > 0 ? 'up' : delta < 0 ? 'down' : 'same';
rows.push({
clientId,
customerName: head.customerName,
// Take the first non-blank — live alerts leave these empty.
accountNo: alerts.find((a) => a.accountNo)?.accountNo || '',
branchCode: alerts.find((a) => a.branchCode)?.branchCode || '',
currentRisk,
alerts,
alertCount: alerts.length,
totalAmount,
largestAmount,
proposedRisk,
recommendedRisk: recommendation.risk,
direction,
directionReason: recommendation.reason,
ruleLabels: [...new Set(alerts.map((a) => a.rule.label))],
firstAlertDate: dates[0],
lastAlertDate: dates[dates.length - 1],
openCount: alerts.filter((a) => REVIEW_STATUS_META[a.status].open).length,
escalated,
needsUpgrade: direction === 'up',
needsDowngrade: direction === 'down'
});
}
// Escalations first, then ratings that need moving up, then down, then volume.
return rows.sort(
(a, b) =>
Number(b.escalated) - Number(a.escalated) ||
Number(b.needsUpgrade) - Number(a.needsUpgrade) ||
Number(b.needsDowngrade) - Number(a.needsDowngrade) ||
b.alertCount - a.alertCount ||
b.totalAmount - a.totalAmount
);
}
/**
* Restricts the alerts to customers whose rating should move a given way.
*
* Direction is a property of the customer, not of a single alert, so it can only
* be applied once the rollup has run — the alert and transaction views are then
* narrowed to the same customers so all three views agree.
*/
export function applyDirectionFilter(
views: RiskAlertView[],
customers: RiskCustomerRow[],
direction: RatingDirection | ''
): { views: RiskAlertView[]; customers: RiskCustomerRow[] } {
if (!direction) {
return { views, customers };
}
const keep = customers.filter((c) => c.direction === direction);
const ids = new Set(keep.map((c) => c.clientId));
return { views: views.filter((v) => ids.has(v.clientId)), customers: keep };
}
/**
* Flattens every alert onto its transactions, newest entry first, so each row
* names the rule that transaction tripped.
*/
export function riskTxnRows(views: RiskAlertView[]): RiskTxnView[] {
const rows: RiskTxnView[] = [];
for (const v of views) {
v.txns.forEach((t, i) => {
rows.push({
...t,
alertId: v.alertId,
alertDate: v.alertDate,
clientId: v.clientId,
customerName: v.customerName,
accountNo: v.accountNo,
branchCode: v.branchCode,
currentRisk: v.currentRisk,
status: v.status,
remarks: v.remarks,
rule: v.rule,
legIndex: i + 1,
legCount: v.txns.length
});
});
}
return rows.sort((a, b) => b.date.localeCompare(a.date) || a.txnId.localeCompare(b.txnId));
}
export function riskReviewSummary(views: RiskAlertView[], customers: RiskCustomerRow[]): RiskReviewSummary {
const byRisk: RiskReviewSummary['byRisk'] = { LOW: 0, MEDIUM: 0, HIGH: 0, UNRATED: 0 };
for (const c of customers) {
byRisk[c.currentRisk ?? 'UNRATED']++;
}
const ruleCounts = new Map<string, { label: string; count: number }>();
for (const v of views) {
const entry = ruleCounts.get(v.ruleCode);
if (entry) {
entry.count++;
} else {
ruleCounts.set(v.ruleCode, { label: v.rule.label, count: 1 });
}
}
const statusCounts = new Map<ReviewStatus, number>();
for (const v of views) {
statusCounts.set(v.status, (statusCounts.get(v.status) || 0) + 1);
}
return {
customers: customers.length,
alerts: views.length,
txns: views.reduce((s, v) => s + v.txns.length, 0),
ctrReportable: views.reduce((s, v) => s + v.txns.filter((t) => t.ctrReportable).length, 0),
openAlerts: views.filter((v) => REVIEW_STATUS_META[v.status].open).length,
escalatedCustomers: customers.filter((c) => c.escalated).length,
upgradeCandidates: customers.filter((c) => c.needsUpgrade).length,
downgradeCandidates: customers.filter((c) => c.needsDowngrade).length,
totalAmount: views.reduce((s, v) => s + v.amount, 0),
byRisk,
byRule: [...ruleCounts.entries()]
.map(
([
code,
v
]) => ({ code, label: v.label, count: v.count })
)
.sort((a, b) => b.count - a.count),
byStatus: [...statusCounts.entries()]
.map(
([
status,
count
]) => ({ status, label: REVIEW_STATUS_META[status].label, count })
)
.sort((a, b) => b.count - a.count)
};
}
/** Everything the screen renders, computed in one pass. */
export interface RiskReviewModel {
alerts: RiskAlertView[];
customers: RiskCustomerRow[];
txns: RiskTxnView[];
summary: RiskReviewSummary;
}
/**
* Single entry point for the screen. Filtering, rollup, direction narrowing and
* the summary all run once per filter change rather than once per template
* binding, which matters because every view reads from the same result.
*/
export function buildRiskReview(alerts: RiskAlertRow[], filter: RiskReviewFilter): RiskReviewModel {
const base = riskAlertViews(alerts, filter);
const narrowed = applyDirectionFilter(base, riskCustomerRows(base), filter.direction);
return {
alerts: narrowed.views,
customers: narrowed.customers,
txns: riskTxnRows(narrowed.views),
summary: riskReviewSummary(narrowed.views, narrowed.customers)
};
}
// ── Period helpers (shared with the other period-filtered reports) ───────────
/** Monday to Saturday containing the given date. */
export function weekRange(anyDateInWeek: string): DateRange {
const d = new Date(`${anyDateInWeek}T00:00:00Z`);
const dow = d.getUTCDay();
const backToMonday = dow === 0 ? 6 : dow - 1;
const monday = new Date(d.getTime() - backToMonday * 86400000);
const saturday = new Date(monday.getTime() + 5 * 86400000);
return { from: monday.toISOString().slice(0, 10), to: saturday.toISOString().slice(0, 10) };
}
/** First to last day of the given yyyy-MM. */
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}-${String(last).padStart(2, '0')}` };
}
+4
View File
@@ -24,6 +24,7 @@ import {
faCalendar, faCalendar,
faCalendarAlt, faCalendarAlt,
faCaretDown, faCaretDown,
faCaretRight,
faChartBar, faChartBar,
faCheck, faCheck,
faCheckCircle, faCheckCircle,
@@ -52,6 +53,7 @@ import {
faFileUpload, faFileUpload,
faFileWord, faFileWord,
faFillDrip, faFillDrip,
faGavel,
faGlobe, faGlobe,
faHandHoldingUsd, faHandHoldingUsd,
faHome, faHome,
@@ -261,6 +263,7 @@ export class IconsModule {
faCalendarAlt, faCalendarAlt,
faCalendarCheck, faCalendarCheck,
faCaretDown, faCaretDown,
faCaretRight,
faChartBar, faChartBar,
faCheck, faCheck,
faCheckCircle, faCheckCircle,
@@ -295,6 +298,7 @@ export class IconsModule {
faFileExcel, faFileExcel,
faFileExport, faFileExport,
faFillDrip, faFillDrip,
faGavel,
faGlobe, faGlobe,
faHandHoldingHand, faHandHoldingHand,
faHandHoldingHeart, faHandHoldingHeart,
@@ -20,195 +20,143 @@
</div> </div>
</div> </div>
<!-- Pill-style tab navigation --> <!-- Pill-style tab navigation — wraps onto multiple rows so every section stays visible and
<nav mat-tab-nav-bar class="ac-tabs" [tabPanel]="tabPanel"> clickable at once, instead of being hidden behind prev/next scroll arrows. -->
<nav class="ac-tabs">
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./checker-inbox']" routerLink="./checker-inbox"
routerLinkActive routerLinkActive="ac-tab--active"
#checkerInbox="routerLinkActive"
[active]="checkerInbox.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="inbox" class="ac-tab__ic"></fa-icon> <fa-icon icon="inbox" class="ac-tab__ic"></fa-icon>
<span>Checker Inbox</span> <span>Checker Inbox</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./client-approval']" routerLink="./client-approval"
routerLinkActive routerLinkActive="ac-tab--active"
#clientApproval="routerLinkActive"
[active]="clientApproval.isActive"
*mifosxHasPermission="'READ_CLIENT'" *mifosxHasPermission="'READ_CLIENT'"
> >
<fa-icon icon="user-check" class="ac-tab__ic"></fa-icon> <fa-icon icon="user-check" class="ac-tab__ic"></fa-icon>
<span>Client Approval</span> <span>Client Approval</span>
</a> </a>
<a <a class="ac-tab" routerLink="./loan-approval" routerLinkActive="ac-tab--active" *mifosxHasPermission="'READ_LOAN'">
mat-tab-link
class="ac-tab"
[routerLink]="['./loan-approval']"
routerLinkActive
#loanApproval="routerLinkActive"
[active]="loanApproval.isActive"
*mifosxHasPermission="'READ_LOAN'"
>
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon> <fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
<span>Loan Approval</span> <span>Loan Approval</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./loan-disbursal']" routerLink="./loan-disbursal"
routerLinkActive routerLinkActive="ac-tab--active"
#loanDisbursal="routerLinkActive"
[active]="loanDisbursal.isActive"
*mifosxHasPermission="'READ_LOAN'" *mifosxHasPermission="'READ_LOAN'"
> >
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon> <fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
<span>Loan Disbursal</span> <span>Loan Disbursal</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./reschedule-loan']" routerLink="./reschedule-loan"
routerLinkActive routerLinkActive="ac-tab--active"
#rescheduleLoan="routerLinkActive"
[active]="rescheduleLoan.isActive"
*mifosxHasPermission="'RESCHEDULE_LOAN'" *mifosxHasPermission="'RESCHEDULE_LOAN'"
> >
<fa-icon icon="calendar-alt" class="ac-tab__ic"></fa-icon> <fa-icon icon="calendar-alt" class="ac-tab__ic"></fa-icon>
<span>Reschedule Loan</span> <span>Reschedule Loan</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./pending-account']" routerLink="./pending-account"
routerLinkActive routerLinkActive="ac-tab--active"
#pendingAccount="routerLinkActive"
[active]="pendingAccount.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon> <fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
<span>Pending Account</span> <span>Pending Account</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./pending-savings-products']" routerLink="./pending-savings-products"
routerLinkActive routerLinkActive="ac-tab--active"
#pendingSavingsProducts="routerLinkActive"
[active]="pendingSavingsProducts.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="piggy-bank" class="ac-tab__ic"></fa-icon> <fa-icon icon="piggy-bank" class="ac-tab__ic"></fa-icon>
<span>Pending Product Edits</span> <span>Pending Product Edits</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./pending-savings-account-edits']" routerLink="./pending-savings-account-edits"
routerLinkActive routerLinkActive="ac-tab--active"
#pendingSavingsAccountEdits="routerLinkActive"
[active]="pendingSavingsAccountEdits.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon> <fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
<span>Pending Account Edits</span> <span>Pending Account Edits</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./pending-share-account-edits']" routerLink="./pending-share-account-edits"
routerLinkActive routerLinkActive="ac-tab--active"
#pendingShareAccountEdits="routerLinkActive"
[active]="pendingShareAccountEdits.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon> <fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
<span>Pending Share Account Edits</span> <span>Pending Share Account Edits</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./pending-client-edits']" routerLink="./pending-client-edits"
routerLinkActive routerLinkActive="ac-tab--active"
#pendingClientEdits="routerLinkActive"
[active]="pendingClientEdits.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon> <fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
<span>Pending Client Edits</span> <span>Pending Client Edits</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./rejected-account']" routerLink="./rejected-account"
routerLinkActive routerLinkActive="ac-tab--active"
#rejectedAccount="routerLinkActive"
[active]="rejectedAccount.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="ban" class="ac-tab__ic"></fa-icon> <fa-icon icon="ban" class="ac-tab__ic"></fa-icon>
<span>Reject Account</span> <span>Reject Account</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./terminated-account']" routerLink="./terminated-account"
routerLinkActive routerLinkActive="ac-tab--active"
#terminatedAccount="routerLinkActive"
[active]="terminatedAccount.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="times-circle" class="ac-tab__ic"></fa-icon> <fa-icon icon="times-circle" class="ac-tab__ic"></fa-icon>
<span>Terminated Account</span> <span>Terminated Account</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./withdrawal-requests']" routerLink="./withdrawal-requests"
routerLinkActive routerLinkActive="ac-tab--active"
#withdrawalRequests="routerLinkActive"
[active]="withdrawalRequests.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon> <fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
<span>Withdrawal Requests</span> <span>Withdrawal Requests</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./deposit-requests']" routerLink="./deposit-requests"
routerLinkActive routerLinkActive="ac-tab--active"
#depositRequests="routerLinkActive"
[active]="depositRequests.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon> <fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
<span>Deposit Requests</span> <span>Deposit Requests</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./disbursement-requests']" routerLink="./disbursement-requests"
routerLinkActive routerLinkActive="ac-tab--active"
#disbursementRequests="routerLinkActive"
[active]="disbursementRequests.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon> <fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
<span>Disbursement & Transfers</span> <span>Disbursement & Transfers</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./chequebook-requests']" routerLink="./chequebook-requests"
routerLinkActive routerLinkActive="ac-tab--active"
#chequebookRequests="routerLinkActive"
[active]="chequebookRequests.isActive"
*mifosxHasPermission="'READ_CHEQUEBOOKPENDINGREQUEST'" *mifosxHasPermission="'READ_CHEQUEBOOKPENDINGREQUEST'"
> >
<fa-icon icon="money-check" class="ac-tab__ic"></fa-icon> <fa-icon icon="money-check" class="ac-tab__ic"></fa-icon>
@@ -216,7 +164,7 @@
</a> </a>
</nav> </nav>
<mat-tab-nav-panel #tabPanel> <div class="ac-tab-panel">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</mat-tab-nav-panel> </div>
</div> </div>
@@ -63,7 +63,10 @@ $amber: #f59e0b;
} }
} }
/* ───────── Pill-style tab navigation ───────── */ /* ───────── Pill-style tab navigation ─────────
A plain wrapping flex row, not mat-tab-nav-bar — with 16 sections, Material's single-row strip
hides most of them behind prev/next pagination arrows. Wrapping keeps every tab visible and one
click away instead of a scroll-and-hunt. */
.ac-tabs { .ac-tabs {
margin-bottom: 20px; margin-bottom: 20px;
background: #fff; background: #fff;
@@ -71,88 +74,51 @@ $amber: #f59e0b;
border-radius: 14px; border-radius: 14px;
padding: 6px; padding: 6px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%); box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
overflow: hidden; display: flex;
flex-wrap: wrap;
::ng-deep { gap: 4px;
.mat-mdc-tab-link-container {
border-bottom: none !important;
}
.mat-mdc-tab-links {
gap: 4px;
}
/* Material draws the active-tab underline indicator with a 2px height —
hide it; our pill background already signals the active tab. */
.mdc-tab-indicator__content--underline,
.mat-mdc-tab-header-pagination-controls-enabled .mdc-tab-indicator__content--underline {
display: none !important;
}
}
} }
.ac-tab { .ac-tab {
display: inline-flex !important; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
height: 38px !important; height: 38px;
min-width: 0 !important; padding: 0 18px;
padding: 0 18px !important; border-radius: 10px;
border-radius: 10px !important; font-family: Inter, 'DM Sans', Roboto, sans-serif;
font-family: Inter, 'DM Sans', Roboto, sans-serif !important; font-size: 13px;
font-size: 13px !important; font-weight: 600;
font-weight: 600 !important; letter-spacing: 0.01em;
letter-spacing: 0.01em !important; color: #64748b;
color: #64748b !important; background: transparent;
background: transparent !important;
transition: all 0.15s ease; transition: all 0.15s ease;
white-space: nowrap; white-space: nowrap;
border: 1px solid transparent !important; border: 1px solid transparent;
opacity: 1 !important; text-decoration: none;
cursor: pointer;
&__ic { &__ic {
font-size: 13px; font-size: 13px;
color: inherit; color: inherit;
} }
&:hover:not(.mdc-tab--active) { &:hover:not(.ac-tab--active) {
background: #f1f5f9 !important; background: #f1f5f9;
color: $navy !important; color: $navy;
} }
/* Material 3 adds `.mdc-tab--active` (and `.mat-mdc-tab-link-active`) to the &.ac-tab--active {
selected link. Both target the same pill state — bold blue gradient. */ background: linear-gradient(135deg, $blue 0%, $blue-d 100%);
&.mdc-tab--active, color: #fff;
&.mat-mdc-tab-link-active {
background: linear-gradient(135deg, $blue 0%, $blue-d 100%) !important;
color: #fff !important;
box-shadow: 0 2px 6px -1px rgb(30 64 175 / 35%); box-shadow: 0 2px 6px -1px rgb(30 64 175 / 35%);
border-color: $blue-d !important; border-color: $blue-d;
/* Material 3 wraps the label in its own span with a color rule that
beats the parent. Force white on every descendant so the active-tab
label is always legible against the dark gradient pill. */
::ng-deep .mdc-tab__text-label,
::ng-deep .mat-mdc-tab-link-content,
::ng-deep span,
.ac-tab__ic {
color: #fff !important;
opacity: 1 !important;
}
} }
} }
@media (width <= 768px) { @media (width <= 768px) {
.ac-tabs {
overflow-x: auto;
::ng-deep .mat-mdc-tab-links {
flex-wrap: nowrap;
}
}
.ac-tab { .ac-tab {
padding: 0 14px !important; padding: 0 14px;
font-size: 12px !important; font-size: 12px;
} }
} }
@@ -8,13 +8,17 @@
/** Angular Imports */ /** Angular Imports */
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs'; import { RouterLinkActive, RouterOutlet } from '@angular/router';
import { RouterLinkActive, RouterLink, RouterOutlet } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** /**
* Approval Center — checker inbox + client/loan approval workflows. * Approval Center — checker inbox + client/loan approval workflows.
*
* The tab strip is a plain wrapping nav (not mat-tab-nav-bar) on purpose: with 16 sections,
* Material's single-row tab strip hides most of them behind prev/next pagination arrows, forcing
* a click-and-scan hunt for whichever one you need. Wrapping onto multiple rows keeps every tab
* visible and one click away.
*/ */
@Component({ @Component({
selector: 'mifosx-checker-inbox-and-tasks', selector: 'mifosx-checker-inbox-and-tasks',
@@ -22,10 +26,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
styleUrls: ['./checker-inbox-and-tasks.component.scss'], styleUrls: ['./checker-inbox-and-tasks.component.scss'],
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
MatTabNav,
MatTabLink,
RouterLinkActive, RouterLinkActive,
MatTabNavPanel,
RouterOutlet, RouterOutlet,
FaIconComponent FaIconComponent
] ]
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */ /* tslint:disable */
export default { export default {
'mifos_x': { 'mifos_x': {
'version': '260801', 'version': '260803',
'hash': 'bd753f49' 'hash': 'b43ed877'
}, },
'allow_switching_backend_instance': true 'allow_switching_backend_instance': true
}; };