Compare commits

...
10 Commits
Author SHA1 Message Date
nitesh 4261f9659e feat(npa): add branch-wise NPA module with realistic data, pagination and recovery actions
Adds a full NPA (Non-Performing Assets) module mirroring the legacy CBS NPA
mega-menu: NPA Reports, NPA Analysis, and NPA Classification screens over a
640-account loan book classified per RBI IRAC norms (Standard through
Loss). Same Head-Office/branch-wise access pattern as the Cash/Clearing/
Transfer summary, 20-row pagination on every large table, and a Recovery
Action workflow (demand notice, SARFAESI, suit filing, OTS) on the Account
Detail screen.

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 13:39:13 +05:30
keshav 0bf337b962 Merge branch 'uate' of https://git.lincpay.tech/CBSNexora/cbsnexora into uate 2026-07-31 11:00:14 +05:30
keshav 1daa12a117 kyc risk and AML & Compliance Checks automate on client creation 2026-07-31 10:58:58 +05:30
66 changed files with 15093 additions and 1720 deletions
+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/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/**
* AML transaction-monitoring API: live alerts raised by the rule engine, the
* two-tier AML Officer / Principal Officer review workflow, STR cases (with
* their 7-working-day FIU filing deadline), monthly CTR reports, and a
* client's risk-category change history.
*/
@Injectable({
providedIn: 'root'
})
export class AmlService {
private http = inject(HttpClient);
// ═══ Alerts (AML Officer) ═══
getAlerts(status?: string): Observable<any[]> {
const params = status ? new HttpParams().set('status', status) : undefined;
return this.http.get<any[]>('/aml/alerts', { params });
}
/** disposition: 'WHITELIST' | 'ESCALATE' */
reviewAlert(alertId: number, disposition: 'WHITELIST' | 'ESCALATE', notes: string): Observable<any> {
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
}
// ═══ STR cases (Principal Officer) ═══
getStrCases(): Observable<any[]> {
return this.http.get<any[]>('/aml/str-cases');
}
getStrCase(strCaseId: number): Observable<any> {
return this.http.get(`/aml/str-cases/${strCaseId}`);
}
getWeeklyDigest(): Observable<any[]> {
return this.http.get<any[]>('/aml/str-cases/weekly-digest');
}
assignPrincipalOfficer(strCaseId: number, principalOfficerUserId: number): Observable<any> {
return this.http.post(`/aml/str-cases/${strCaseId}/assign`, { principalOfficerUserId });
}
/** conclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' */
concludeStrCase(
strCaseId: number,
conclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS',
investigationNotes: string,
sourceOfFundsNotes: string
): Observable<any> {
return this.http.post(`/aml/str-cases/${strCaseId}/conclude`, {
conclusion,
investigationNotes,
sourceOfFundsNotes
});
}
fileStr(strCaseId: number, referenceNo: string): Observable<any> {
return this.http.post(`/aml/str-cases/${strCaseId}/file`, { referenceNo });
}
// ═══ CTR reports (mechanical, threshold-based) ═══
getCtrReports(): Observable<any[]> {
return this.http.get<any[]>('/aml/ctr-reports');
}
/** month format: yyyy-MM */
generateCtrReport(month: string): Observable<any> {
return this.http.post('/aml/ctr-reports/generate', { month });
}
// ═══ Client risk-category history ═══
getRiskCategoryHistory(clientId: number): Observable<any[]> {
return this.http.get<any[]>(`/clients/${clientId}/risk-category-history`);
}
}
+4
View File
@@ -65,6 +65,8 @@ import { DeafModule } from './deaf/deaf.module';
import { DicgcModule } from './dicgc/dicgc.module';
import { FiuModule } from './fiu/fiu.module';
import { SftModule } from './sft/sft.module';
import { InvestmentModule } from './investment/investment.module';
import { NpaModule } from './npa/npa.module';
import { DigitalModule } from './digital/digital.module';
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
import { AccountsModule } from './accounts/accounts.module';
@@ -172,6 +174,8 @@ export function HttpLoaderFactory(http: HttpClient) {
DicgcModule,
FiuModule,
SftModule,
InvestmentModule,
NpaModule,
DigitalModule,
LetterMgmtModule,
AccountsModule,
@@ -0,0 +1,266 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">SUB LEDGER · TRANSACTIONS · {{ scopeName | uppercase }}</div>
<h1 class="pg-title">Cash / Clearing / Transfer - Summary Report</h1>
<p class="pg-sub">
Opening, credit, debit and closing position across Cash, Transfer, Clearing and Digital channels as on
{{ asOnDate }}.
</p>
</div>
<div class="pg-toolbar">
@if (isHeadOffice) {
<label class="pg-field">
<span>Branch</span>
<select class="pg-select" [ngModel]="selectedBranchCode" (ngModelChange)="setBranch($event)">
<option value="ALL">All Branches (Consolidated)</option>
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
}
</select>
</label>
}
<button type="button" class="pg-btn" [class.pg-btn--active]="showDetails" (click)="toggleDetails()">
{{ showDetails ? 'Summary View' : 'Transaction Details' }}
</button>
</div>
</header>
<div class="cctr-grand-summary">
<div class="cctr-grand-summary__item">
<span class="cctr-grand-summary__label">Opening</span>
<span class="cctr-grand-summary__value">₹{{ grandOpening | number: '1.2-2' }}</span>
</div>
<div class="cctr-grand-summary__item">
<span class="cctr-grand-summary__label">Credit</span>
<span class="cctr-grand-summary__value col-in">₹{{ grandCredit | number: '1.2-2' }}</span>
</div>
<div class="cctr-grand-summary__item">
<span class="cctr-grand-summary__label">Debit</span>
<span class="cctr-grand-summary__value col-out">₹{{ grandDebit | number: '1.2-2' }}</span>
</div>
<div class="cctr-grand-summary__item">
<span class="cctr-grand-summary__label">Closing</span>
<span class="cctr-grand-summary__value">₹{{ grandClosing | number: '1.2-2' }}</span>
</div>
</div>
@if (isHeadOffice && selectedBranchCode === 'ALL') {
<div class="pg-card">
<div class="pos-section-label">Branch-wise Break-up — click a branch to drill in</div>
<table class="pg-table">
<thead>
<tr>
<th>Branch</th>
<th class="num">Opening</th>
<th class="num col-in">Credit</th>
<th class="num col-out">Debit</th>
<th class="num col-net">Closing</th>
</tr>
</thead>
<tbody>
@for (b of branchBreakup; track b.code) {
<tr class="cctr-branch-row" (click)="setBranch(b.code)">
<td>{{ b.name }}</td>
<td class="num">₹{{ b.opening | number: '1.2-2' }}</td>
<td class="num col-in">₹{{ b.credit | number: '1.2-2' }}</td>
<td class="num col-out">₹{{ b.debit | number: '1.2-2' }}</td>
<td class="num col-net">₹{{ b.closing | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td>All Branches</td>
<td class="num">₹{{ grandOpening | number: '1.2-2' }}</td>
<td class="num">₹{{ grandCredit | number: '1.2-2' }}</td>
<td class="num">₹{{ grandDebit | number: '1.2-2' }}</td>
<td class="num">₹{{ grandClosing | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
<div class="cctr-tabs">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'cash'"
(click)="setTab('cash')"
>
Cash
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'transfer'"
(click)="setTab('transfer')"
>
Transfer
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'clearing'"
(click)="setTab('clearing')"
>
Clearing
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'digital'"
(click)="setTab('digital')"
>
Digital
</button>
</div>
<!-- Summary (Opening | Credit | Debit | Closing) -->
@if (!showDetails) {
<div class="pg-card">
@if (activeTab === 'digital') {
<div class="pos-section-label">RTGS · NEFT · UPI · ATM · NACH — Overall Position</div>
}
<table class="pg-table">
<thead>
<tr>
@if (activeTab !== 'digital') {
<th>GL Code</th>
}
<th>Particulars</th>
<th class="num">Opening</th>
<th class="num col-in">Credit</th>
<th class="num col-out">Debit</th>
<th class="num col-net">Closing</th>
</tr>
</thead>
<tbody>
@for (r of rowsForActiveTab; track r.code) {
<tr>
@if (activeTab !== 'digital') {
<td>
<span class="pg-chip">{{ r.code }}</span>
</td>
}
<td>{{ r.name }}</td>
<td class="num">₹{{ r.opening | number: '1.2-2' }}</td>
<td class="num col-in">₹{{ rowCredit(r, txnsForActiveTab) | number: '1.2-2' }}</td>
<td class="num col-out">₹{{ rowDebit(r, txnsForActiveTab) | number: '1.2-2' }}</td>
<td class="num col-net">₹{{ closing(r, txnsForActiveTab) | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td [attr.colspan]="activeTab !== 'digital' ? 2 : 1">Total</td>
<td class="num">₹{{ total(rowsForActiveTab, txnsForActiveTab, 'opening') | number: '1.2-2' }}</td>
<td class="num">₹{{ total(rowsForActiveTab, txnsForActiveTab, 'credit') | number: '1.2-2' }}</td>
<td class="num">₹{{ total(rowsForActiveTab, txnsForActiveTab, 'debit') | number: '1.2-2' }}</td>
<td class="num">₹{{ totalClosing(rowsForActiveTab, txnsForActiveTab) | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
<!-- Transaction Details (all txns for the selected category, optionally filtered by GL/channel) -->
@if (showDetails) {
@if (rowsForActiveTab.length > 1) {
<div class="cctr-tabs">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="glFilter === null"
(click)="setGlFilter(null)"
>
All
</button>
@for (r of rowsForActiveTab; track r.code) {
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="glFilter === r.code"
(click)="setGlFilter(r.code)"
>
{{ filterLabel(r) }}
</button>
}
</div>
}
<div class="pg-card">
<div class="pos-section-label">
{{
activeTab === 'cash'
? 'Cash'
: activeTab === 'transfer'
? 'Transfer'
: activeTab === 'clearing'
? 'Clearing'
: 'Digital'
}}
— Transaction Details
@if (glFilter !== null) {
· {{ glFilter }}
}
</div>
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>Voucher / Ref No</th>
<th>Account No</th>
<th>Account Name / Reference</th>
<th>Particulars</th>
<th class="num col-out">Debit</th>
<th class="num col-in">Credit</th>
</tr>
</thead>
<tbody>
@for (t of filteredTxns; track t.voucherNo) {
<tr>
<td>{{ asOnDate }}</td>
<td>
<span class="pg-chip">{{ display(t.voucherNo) }}</span>
</td>
<td>{{ display(t.accountNo) }}</td>
<td>{{ t.accountName }}</td>
<td>{{ t.particulars }}</td>
<td class="num col-out">
@if (t.debit > 0) {
₹{{ t.debit | number: '1.2-2' }}
}
</td>
<td class="num col-in">
@if (t.credit > 0) {
₹{{ t.credit | number: '1.2-2' }}
}
</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="pg-empty">No transactions for this filter.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="5">Total</td>
<td class="num">₹{{ txnTotal(filteredTxns, 'debit') | number: '1.2-2' }}</td>
<td class="num">₹{{ txnTotal(filteredTxns, 'credit') | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
</div>
@@ -0,0 +1,73 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.cctr-tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.cctr-total-row {
td {
background: #f1f5f9;
font-weight: 800;
border-top: 2px solid #cbd5e1;
border-bottom: none;
color: #0f172a;
}
}
.cctr-grand-summary {
display: flex;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
&__item {
flex: 1;
padding: 14px 20px;
display: flex;
flex-direction: column;
gap: 4px;
border-right: 1px solid #e2e8f0;
&:last-child {
border-right: none;
}
}
&__label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #64748b;
}
&__value {
font-size: 20px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #0f172a;
}
}
.cctr-subtext {
font-size: 11px;
color: #94a3b8;
margin-top: 2px;
}
.cctr-branch-row {
cursor: pointer;
&:hover td {
background: #eff6ff;
}
}
+11
View File
@@ -179,6 +179,17 @@ const routes: Routes = [
loadComponent: () =>
import('./my-deposit-requests/my-deposit-requests.component').then((m) => m.MyDepositRequestsComponent),
data: { title: 'My Deposit Requests', breadcrumb: 'My Deposit Requests' }
},
{
path: 'cash-clearing-transfer-summary',
loadComponent: () =>
import('./cash-clearing-transfer-summary/cash-clearing-transfer-summary.component').then(
(m) => m.CashClearingTransferSummaryComponent
),
data: {
title: 'Cash / Clearing / Transfer - Summary Report',
breadcrumb: 'Cash / Clearing / Transfer - Summary Report'
}
}
]
}
@@ -12,16 +12,16 @@
* have meaningful variety out of the box.
*/
export const SAMPLE_CKYC_FILE = `10|24401|IN4711|MH|30|31-08-2020|V1.1|||||
20|1|01|01|||||||||||01|||01|10078|MR|RAVI|KUMAR|SHARMA|||||||01|MR|RAJESH|KUMAR|SHARMA||MRS|SUNITA|DEVI|||M|02|IN|B-01|10-08-1985|||||||||01|02|||||02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||Y|02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||||||||||||||||9892011001|||||31-08-2020|MUMBAI MAIN|31-08-2020|01|ANJALI MEHTA|ASSISTANT MANAGER|HEAD OFFICE|4|BHARAT URBAN BANK|IN4711|1|0||0|4||||||
20|1|01|01|||||||||||01|||01|10078|MR|RAVI|KUMAR|SHARMA|||||||01|MR|RAJESH|KUMAR|SHARMA||MRS|SUNITA|DEVI|||M|02|IN|B-01|10-08-1985|||||||||01|02|||||02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||Y|02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||||||||||||||||9892011001|||||31-08-2020|MUMBAI MAIN|31-08-2020|01|ANJALI MEHTA|ASSISTANT MANAGER|HEAD OFFICE|4|CBSNEXORA BANK|IN4711|1|0||0|4||||||
30|1|C|AAQPS8560J||01|02|||||
70|1|10078_Sign.jpeg|09|01|01||||
70|1|10078_Photo.jpeg|02|01|01||||
70|1|10078_AP.jpg|04|01|01||||
20|2|01|01|||||||||||01|||01|10079|MRS|PRIYA||PATEL|||||||01|MR|HARISH|PATEL|||MRS|MEENA|PATEL|||F|01|IN|O-04|22-03-1990|||||||||01|02|||||02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||Y|02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||||||||||||||||9822055002|||||31-08-2020|PUNE FC ROAD|31-08-2020|01|VIKRAM SINGH|BRANCH MANAGER|HEAD OFFICE|4|BHARAT URBAN BANK|IN4711|1|0||0|3||||||
20|2|01|01|||||||||||01|||01|10079|MRS|PRIYA||PATEL|||||||01|MR|HARISH|PATEL|||MRS|MEENA|PATEL|||F|01|IN|O-04|22-03-1990|||||||||01|02|||||02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||Y|02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||||||||||||||||9822055002|||||31-08-2020|PUNE FC ROAD|31-08-2020|01|VIKRAM SINGH|BRANCH MANAGER|HEAD OFFICE|4|CBSNEXORA BANK|IN4711|1|0||0|3||||||
30|2|E|XXXXXXXX2210||01|02|||||
70|2|10079_Sign.jpeg|09|01|01||||
70|2|10079_Photo.jpeg|02|01|01||||
20|3|01|01|||||||||||01|||01|10080|MR|ARJUN||DESHMUKH|||||||01|MR|VIJAY|DESHMUKH|||MRS|REKHA|DESHMUKH|||M|01|IN|B-01|15-01-1978|||||||||01|02|||||02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||Y|02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||||||||||||||||9823033003|||||30-08-2020|NAGPUR DHARAMPETH|30-08-2020|01|RAHUL JOSHI|ASSISTANT MANAGER|HEAD OFFICE|4|BHARAT URBAN BANK|IN4711|1|0||0|3||||||
20|3|01|01|||||||||||01|||01|10080|MR|ARJUN||DESHMUKH|||||||01|MR|VIJAY|DESHMUKH|||MRS|REKHA|DESHMUKH|||M|01|IN|B-01|15-01-1978|||||||||01|02|||||02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||Y|02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||||||||||||||||9823033003|||||30-08-2020|NAGPUR DHARAMPETH|30-08-2020|01|RAHUL JOSHI|ASSISTANT MANAGER|HEAD OFFICE|4|CBSNEXORA BANK|IN4711|1|0||0|3||||||
30|3|C|AGTPD1144K||01|02|||||
70|3|10080_Sign.jpeg|09|01|01||||
70|3|10080_AP.jpg|04|01|01||||
@@ -533,6 +533,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
screeningLogId: number | null = null;
/** Snapshot of the name that was last verified — used to invalidate verification when name changes */
private verifiedName: string = '';
/** True while prefillForEdit() is patching historical monthly/annual values — see linkMonthlyAnnual(). */
private suppressMonthlyAnnualLink = false;
/**
* @param {FormBuilder} formBuilder Form Builder
@@ -709,6 +711,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
const data = this.clientData;
this.loadExistingSeniorPwdDocuments();
// Loading an existing client's independently-saved monthly/annual values must not run
// through the auto-calc in linkMonthlyAnnual() — patching monthlyIncome then annualIncome
// (or vice versa) would otherwise have each overwrite the other via its valueChanges,
// instead of keeping the exact historical values as saved.
this.suppressMonthlyAnnualLink = true;
// Patch all base controls that exist on the form
this.createClientForm.patchValue({
officeId: data.officeId,
@@ -743,6 +751,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
? new Date(data.timeline.submittedOnDate)
: this.settingsService.businessDate
});
this.suppressMonthlyAnnualLink = false;
// Patch name fields (already created by buildDependencies via legalFormId subscription)
const legalFormId = data.legalForm?.id || LegalFormId.PERSON;
@@ -954,6 +963,43 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
Validators.required
]
});
this.linkMonthlyAnnual('monthlyIncome', 'annualIncome');
this.linkMonthlyAnnual('monthlyTurnover', 'annualTurnover');
}
/**
* Keeps a Monthly/Annual pair of amount fields in sync at a 12x ratio — typing into either
* one auto-fills the other. Uses { emitEvent: false } on the write side so the two
* subscriptions don't trigger each other back and forth.
*/
/** Rounds to at most 2 decimals without padding whole numbers with a trailing ".00". */
private round2(n: number): string {
return (Math.round(n * 100) / 100).toString();
}
private linkMonthlyAnnual(monthlyControlName: string, annualControlName: string): void {
const monthly = this.createClientForm.get(monthlyControlName);
const annual = this.createClientForm.get(annualControlName);
if (!monthly || !annual) {
return;
}
monthly.valueChanges.subscribe((value) => {
if (this.suppressMonthlyAnnualLink) {
return;
}
const num = parseFloat(value);
annual.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 12) : '', { emitEvent: false });
});
annual.valueChanges.subscribe((value) => {
if (this.suppressMonthlyAnnualLink) {
return;
}
const num = parseFloat(value);
monthly.setValue(!isNaN(num) && num >= 0 ? this.round2(num / 12) : '', { emitEvent: false });
});
}
/**
@@ -2134,7 +2180,15 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
return generalDetails;
}
get calculatedRisk(): { category: number; reason: string } {
get calculatedRisk(): {
category: number;
reason: string;
amlCheckStatus?: string;
bannedEntityMatch?: string;
willfulDefaulter?: string;
ecgcSalFlag?: string;
dormantFlag?: string;
} {
const legalFormId = this.createClientForm.get('legalFormId')?.value || 1; // 1 = Person, 2 = Entity
const occCatId = this.createClientForm.get('occupationCategoryCvId')?.value;
const occId = this.createClientForm.get('occupationCvId')?.value;
@@ -2151,6 +2205,21 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
// Evaluate rules from DB (ordered by orderPosition ASC)
if (this.riskRules && this.riskRules.length > 0) {
for (const rule of this.riskRules) {
// Skip transaction/AML rules that have no KYC criteria
if (
rule.legalFormId == null &&
rule.occupationCategoryCvId == null &&
rule.occupationCvId == null &&
rule.minMonthlyIncome == null &&
rule.maxMonthlyIncome == null &&
rule.minAnnualIncome == null &&
rule.maxAnnualIncome == null &&
rule.minAnnualTurnover == null &&
rule.maxAnnualTurnover == null
) {
continue;
}
// Match legal form
if (rule.legalFormId != null && rule.legalFormId !== legalFormId) {
continue;
@@ -2191,15 +2260,39 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
}
// All matched!
return { category: rule.riskCategory, reason: rule.reason };
return {
category: rule.riskCategory,
reason: rule.reason,
amlCheckStatus: rule.amlCheckStatus || (rule.riskCategory === 3 ? 'FLAGGED' : 'CLEAR'),
bannedEntityMatch: rule.bannedEntityMatch || 'NO',
willfulDefaulter: rule.willfulDefaulter || 'NO',
ecgcSalFlag: rule.ecgcSalFlag || 'NO',
dormantFlag: rule.dormantFlag || 'ACTIVE'
};
}
}
// Default Fallbacks
if (legalFormId !== LegalFormId.PERSON) {
return { category: 2, reason: 'Non-Individual / Entity profile (Default Medium Risk)' };
return {
category: 2,
reason: 'Non-Individual / Entity profile (Default Medium Risk)',
amlCheckStatus: 'CLEAR',
bannedEntityMatch: 'NO',
willfulDefaulter: 'NO',
ecgcSalFlag: 'NO',
dormantFlag: 'ACTIVE'
};
}
return { category: 1, reason: 'Default Low Risk profile (Salary / Service / Agriculture / Others)' };
return {
category: 1,
reason: 'Default Low Risk profile (Salary / Service / Agriculture / Others)',
amlCheckStatus: 'CLEAR',
bannedEntityMatch: 'NO',
willfulDefaulter: 'NO',
ecgcSalFlag: 'NO',
dormantFlag: 'ACTIVE'
};
}
}
@@ -1251,6 +1251,12 @@
</h4>
<div class="form-grid">
<div class="checkbox-field override-checkbox-wrapper full-width">
<mat-checkbox formControlName="allowAmlOverride" [disabled]="readOnly">
Override Auto-calculated AML &amp; Compliance Checks
</mat-checkbox>
</div>
<mat-form-field appearance="outline" class="form-field">
<mat-label>AML Check Status</mat-label>
<mat-select required formControlName="amlCheckStatus">
@@ -71,7 +71,15 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
@Input() entityName: string = '';
/** Read-only mode — disables form editing until user clicks edit icon */
@Input() readOnly = false;
@Input() calculatedRisk: { category: number; reason: string } | null = null;
@Input() calculatedRisk: {
category: number;
reason: string;
amlCheckStatus?: string;
bannedEntityMatch?: string;
willfulDefaulter?: string;
ecgcSalFlag?: string;
dormantFlag?: string;
} | null = null;
today = new Date();
kycId: string = '';
@@ -458,6 +466,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
kycDocExpiryDate: [''],
riskCategory: [1],
allowRiskOverride: [false],
allowAmlOverride: [false],
remarks: [''],
amlCheckStatus: [
'CLEAR',
@@ -535,9 +544,35 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
}
});
this.kycForm.get('allowAmlOverride')?.valueChanges.subscribe((allowed: boolean) => {
const amlControls = [
this.kycForm.get('amlCheckStatus'),
this.kycForm.get('bannedEntityMatch'),
this.kycForm.get('willfulDefaulter'),
this.kycForm.get('ecgcSalFlag'),
this.kycForm.get('dormantFlag')
];
if (allowed) {
if (!this.readOnly) {
amlControls.forEach((ctrl) => ctrl?.enable());
}
} else {
amlControls.forEach((ctrl) => ctrl?.disable());
this.applyAutoCalculatedAmlFlags();
}
});
if (!this.kycForm.get('allowRiskOverride')?.value) {
this.kycForm.get('riskCategory')?.disable();
}
if (!this.kycForm.get('allowAmlOverride')?.value) {
this.kycForm.get('amlCheckStatus')?.disable();
this.kycForm.get('bannedEntityMatch')?.disable();
this.kycForm.get('willfulDefaulter')?.disable();
this.kycForm.get('ecgcSalFlag')?.disable();
this.kycForm.get('dormantFlag')?.disable();
}
}
/** Seed the document-type picker from defaults (entity RBI docs vs. individual OVDs,
@@ -645,6 +680,13 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
if (!this.kycForm.get('allowRiskOverride')?.value) {
this.kycForm.get('riskCategory')?.disable({ emitEvent: false });
}
if (!this.kycForm.get('allowAmlOverride')?.value) {
this.kycForm.get('amlCheckStatus')?.disable({ emitEvent: false });
this.kycForm.get('bannedEntityMatch')?.disable({ emitEvent: false });
this.kycForm.get('willfulDefaulter')?.disable({ emitEvent: false });
this.kycForm.get('ecgcSalFlag')?.disable({ emitEvent: false });
this.kycForm.get('dormantFlag')?.disable({ emitEvent: false });
}
}
}
if (changes['calculatedRisk']) {
@@ -657,6 +699,11 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
riskControl.setValue(newRisk.category);
riskControl.markAsDirty();
}
const allowAmlOverride = this.kycForm.get('allowAmlOverride')?.value;
if (!allowAmlOverride) {
this.applyAutoCalculatedAmlFlags(newRisk);
}
} else {
this.riskCalculationReason = '';
}
@@ -871,6 +918,13 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
// Risk & Remarks
riskCategory: data.riskCategory || 1,
allowRiskOverride: this.calculatedRisk ? this.calculatedRisk.category !== (data.riskCategory || 1) : false,
allowAmlOverride: this.calculatedRisk
? this.calculatedRisk.amlCheckStatus !== (data.amlCheckStatus || 'CLEAR') ||
this.calculatedRisk.bannedEntityMatch !== (data.bannedEntityMatch || 'NO') ||
this.calculatedRisk.willfulDefaulter !== (data.willfulDefaulter || 'NO') ||
this.calculatedRisk.ecgcSalFlag !== (data.ecgcSalFlag || 'NO') ||
this.calculatedRisk.dormantFlag !== (data.dormantFlag || 'ACTIVE')
: false,
remarks: data.remarks || '',
hasPan: !!data.panNumber,
// AML & Compliance
@@ -1805,6 +1859,21 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
v.form15Applicable
);
}
private applyAutoCalculatedAmlFlags(risk = this.calculatedRisk): void {
if (!risk) return;
this.kycForm.get('amlCheckStatus')?.setValue(risk.amlCheckStatus || 'CLEAR');
this.kycForm.get('bannedEntityMatch')?.setValue(risk.bannedEntityMatch || 'NO');
this.kycForm.get('willfulDefaulter')?.setValue(risk.willfulDefaulter || 'NO');
this.kycForm.get('ecgcSalFlag')?.setValue(risk.ecgcSalFlag || 'NO');
this.kycForm.get('dormantFlag')?.setValue(risk.dormantFlag || 'ACTIVE');
this.kycForm.get('amlCheckStatus')?.markAsDirty();
this.kycForm.get('bannedEntityMatch')?.markAsDirty();
this.kycForm.get('willfulDefaulter')?.markAsDirty();
this.kycForm.get('ecgcSalFlag')?.markAsDirty();
this.kycForm.get('dormantFlag')?.markAsDirty();
}
}
/**
@@ -392,6 +392,22 @@ export class AuthenticationService {
return roleNames.some((n) => n === 'clerk');
}
/** Returns true when the logged-in user has the "Principal Officer" role (STR conclusion/filing authority). */
isPrincipalOfficerUser(): boolean {
const creds = this.getCredentials();
if (!creds || !creds.roles) return false;
const roleNames: string[] = (creds.roles as any[]).map((r) => (r.name || r.role || '').toLowerCase().trim());
return roleNames.some((n) => n === 'principal officer');
}
/** Returns true when the logged-in user has the "AML Officer" role (first-level alert review). */
isAmlOfficerUser(): boolean {
const creds = this.getCredentials();
if (!creds || !creds.roles) return false;
const roleNames: string[] = (creds.roles as any[]).map((r) => (r.name || r.role || '').toLowerCase().trim());
return roleNames.some((n) => n === 'aml officer');
}
/**
* Returns true ONLY for regular cashier role (not Head Cashier).
* Regular cashier → full-screen transaction entry, no nav.
@@ -424,6 +440,18 @@ export class AuthenticationService {
return roleNames.some((n) => n === 'cashier');
}
/**
* Returns true when the logged-in user belongs to the Head Office.
* Fineract seeds Head Office as officeId 1 with hierarchy "."; office name is
* checked too since some deployments rename/reseed the root office.
*/
isHeadOfficeUser(): boolean {
const creds = this.getCredentials();
if (!creds) return false;
if (creds.officeId === 1) return true;
return (creds.officeName || '').toLowerCase().includes('head office');
}
/**
* Sets the user credentials.
*
+247 -34
View File
@@ -362,6 +362,10 @@
<fa-icon icon="university" class="ni-ico"></fa-icon>RBI
<fa-icon [icon]="rbiMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" (click)="investmentMenuOpen = !investmentMenuOpen; rbiMenuOpen = false; glMenuOpen = false" *mifosxHasPermission="'MENU_RBI'">
<fa-icon icon="chart-line" class="ni-ico"></fa-icon>Investment
<fa-icon [icon]="investmentMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="digitalMenu" *mifosxHasPermission="'MENU_DIGITAL'">
<fa-icon icon="mobile-alt" class="ni-ico"></fa-icon>Digital
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
@@ -492,6 +496,15 @@
<fa-icon icon="university" class="ni-ico"></fa-icon>RBI
<fa-icon [icon]="rbiMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
<a
class="ni"
tabindex="0"
(click)="investmentMenuOpen = !investmentMenuOpen; rbiMenuOpen = false; glMenuOpen = false"
*mifosxHasPermission="'MENU_RBI'"
>
<fa-icon icon="chart-line" class="ni-ico"></fa-icon>Investment
<fa-icon [icon]="investmentMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="reportsMenu" *mifosxHasPermission="'MENU_REPORTS'">
<fa-icon icon="chart-bar" class="ni-ico"></fa-icon>Reports
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
@@ -653,6 +666,7 @@
<a
class="gl-link"
[class.hidden]="!matchesSubLedgerSearch('Cash / Clearing / Transfer')"
routerLink="/cashier/cash-clearing-transfer-summary"
(click)="subLedgerOpen = false"
><fa-icon icon="money-bill" class="gl-icon"></fa-icon> Cash / Clearing / Transfer</a
>
@@ -701,8 +715,11 @@
</div>
<div class="gl-group">
<div class="gl-group-title">Deposits</div>
<a class="gl-link" [class.hidden]="!matchesSubLedgerSearch('FD / RD Entry')" (click)="subLedgerOpen = false"
><fa-icon icon="piggy-bank" class="gl-icon"></fa-icon> FD / RD Entry</a
<a
class="gl-link"
[class.hidden]="!matchesSubLedgerSearch('FD / RD Entry')"
(click)="subLedgerOpen = false; openFdRdEntryDialog()"
><fa-icon icon="piggy-bank" class="gl-icon" style="color: #0f766e"></fa-icon> FD / RD Entry</a
>
<a class="gl-link" [class.hidden]="!matchesSubLedgerSearch('DDS Entry')" (click)="subLedgerOpen = false"
><fa-icon icon="coins" class="gl-icon"></fa-icon> DDS Entry</a
@@ -808,8 +825,11 @@
<div class="gl-columns">
<div class="gl-group">
<div class="gl-group-title">Financial Statements</div>
<a class="gl-link" [class.hidden]="!matchesGlSearch('Trial Balance')" (click)="glMenuOpen = false"
><fa-icon icon="balance-scale" class="gl-icon"></fa-icon> Trial Balance</a
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Trial Balance')"
(click)="glMenuOpen = false; openAccountingReportsDialog('GL-DR-CR')"
><fa-icon icon="balance-scale" class="gl-icon" style="color: #b45309"></fa-icon> Trial Balance</a
>
<a
class="gl-link"
@@ -864,8 +884,12 @@
(click)="glMenuOpen = false"
><fa-icon icon="users" class="gl-icon"></fa-icon> Client wise Balance Report</a
>
<a class="gl-link" [class.hidden]="!matchesGlSearch('Contingent Expense')" (click)="glMenuOpen = false"
><fa-icon icon="exclamation-circle" class="gl-icon"></fa-icon> Contingent Expense</a
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Contingent Expense')"
(click)="glMenuOpen = false; openAccountingReportsDialog('CONTINGENT-LIAB')"
><fa-icon icon="exclamation-circle" class="gl-icon" style="color: #b45309"></fa-icon> Contingent
Expense</a
>
<a class="gl-link" [class.hidden]="!matchesGlSearch('Banker Head Report')" (click)="glMenuOpen = false"
><fa-icon icon="university" class="gl-icon"></fa-icon> Banker Head Report</a
@@ -1155,7 +1179,7 @@
<fa-icon icon="user-slash" class="menu-item-icon" style="color: #c2410c"></fa-icon> Non-Customer Entry
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="lock" class="menu-item-icon"></fa-icon> Locker</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item routerLink="/investment/bonds" tabindex="0">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> SLR and Non SLR Investment Report
</button>
<button mat-menu-item tabindex="0" (click)="openCicReportingDialog()">
@@ -1167,21 +1191,17 @@
<button mat-menu-item tabindex="0">
<fa-icon icon="chart-bar" class="menu-item-icon"></fa-icon> Depreciation Register
</button>
<button mat-menu-item routerLink="/fiu" tabindex="0">
<fa-icon icon="shield-alt" class="menu-item-icon" style="color: #dc2626"></fa-icon> Financial Intelligence
Unit (FIU-IND)
</button>
</div>
<!-- Investment -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Investment</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item routerLink="/investment/dashboard" tabindex="0">
<fa-icon icon="calendar-alt" class="menu-item-icon"></fa-icon> Due Date Diary for All Investment
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item routerLink="/investment/reports" tabindex="0">
<fa-icon icon="dollar-sign" class="menu-item-icon"></fa-icon> Total Int Received &amp; Receivable Report
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item routerLink="/investment/reports" tabindex="0">
<fa-icon icon="piggy-bank" class="menu-item-icon"></fa-icon> Bucket Wise Investment
</button>
<button mat-menu-item routerLink="/deaf" tabindex="0">
@@ -1212,39 +1232,41 @@
<!-- NPA Reports -->
<div class="deposit-menu-group">
<div class="deposit-group-title">NPA Reports</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/npa/reports/interest">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> NPA Interest
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/npa/reports/status">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> NPA Status Report
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/npa/reports/recovery">
<fa-icon icon="undo" class="menu-item-icon"></fa-icon> NPA Recovery Report
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/npa/reports/summary">
<fa-icon icon="clipboard-list" class="menu-item-icon"></fa-icon> NPA Summary Report
</button>
</div>
<!-- NPA Analysis -->
<div class="deposit-menu-group">
<div class="deposit-group-title">NPA Analysis</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/npa/reports/account-detail">
<fa-icon icon="list-alt" class="menu-item-icon"></fa-icon> NPA Account Detail Report
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/npa/reports/movement">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> NPA Movement Report
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/npa/reports/provision">
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> NPA with Provision and Required Provision
</button>
</div>
<!-- NPA Classification -->
<div class="deposit-menu-group">
<div class="deposit-group-title">NPA Classification</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/npa/reports/probable">
<fa-icon icon="exclamation-circle" class="menu-item-icon"></fa-icon> Probable NPA
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="flag" class="menu-item-icon"></fa-icon> SMA Report</button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/sma">
<fa-icon icon="flag" class="menu-item-icon"></fa-icon> SMA Report
</button>
</div>
</div>
</mat-menu>
@@ -1571,7 +1593,8 @@
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('CTR / STR')"
(click)="rbiMenuOpen = false; openCtrStrDialog()"
routerLink="/fiu"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="calculator" class="gl-icon"></fa-icon> CTR / STR
</a>
@@ -1668,15 +1691,24 @@
>
<fa-icon icon="bell" class="gl-icon"></fa-icon> Daily Position Notice
</a>
<a class="gl-link" [class.hidden]="!matchesRbiSearch('R/SLR')" (click)="rbiMenuOpen = false">
<fa-icon icon="lock" class="gl-icon"></fa-icon> R/SLR
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('AML')"
routerLink="/rbi/aml"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="shield-alt" class="gl-icon"></fa-icon> AML
</a>
</div>
<!-- OSS Reports -->
<div class="gl-group">
<div class="gl-group-title">OSS Reports</div>
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 1 XBRL/CIMS')" (click)="rbiMenuOpen = false">
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 1 XBRL/CIMS
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('OSS 1 XBRL/CIMS')"
(click)="rbiMenuOpen = false; openCimsXbrlDialog('RAL')"
>
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 1 XBRL/CIMS
</a>
<a
class="gl-link"
@@ -1685,14 +1717,40 @@
>
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 2 XBRL
</a>
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 3 XBRL/CIMS')" (click)="rbiMenuOpen = false">
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 3 XBRL/CIMS
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('OSS 3 XBRL/CIMS')"
(click)="rbiMenuOpen = false; openCimsXbrlDialog('OSS-2')"
>
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 3 XBRL/CIMS
</a>
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 4/OSS 5/OSS 6')" (click)="rbiMenuOpen = false">
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 4 / OSS 5 / OSS 6
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('OSS 4')"
(click)="rbiMenuOpen = false; openCimsXbrlDialog('OSS-4')"
>
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 4
</a>
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 7')" (click)="rbiMenuOpen = false">
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 7
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('OSS 5')"
(click)="rbiMenuOpen = false; openCimsXbrlDialog('OSS-5')"
>
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 5
</a>
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('OSS 6')"
(click)="rbiMenuOpen = false; openCimsXbrlDialog('OSS-6')"
>
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 6
</a>
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('OSS 7')"
(click)="rbiMenuOpen = false; openCimsXbrlDialog('OSS-7')"
>
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 7
</a>
</div>
<!-- CIMS / XBRL Returns -->
@@ -1823,6 +1881,161 @@
</div>
}
<!-- Investment (Treasury) Mega Menu -->
@if (investmentMenuOpen) {
<div class="gl-backdrop" (click)="investmentMenuOpen = false"></div>
<div class="gl-mega-menu rbi-mega-menu">
<div class="gl-mega-menu-header">
<fa-icon icon="chart-line" class="gl-mega-menu-header-icon"></fa-icon>
<span>Investment</span>
</div>
<input
type="text"
class="gl-search"
placeholder="Search Investment..."
(input)="investmentSearch = $any($event.target).value"
/>
<div class="rbi-columns">
<!-- Dashboard -->
<div class="gl-group">
<div class="gl-group-title">Dashboard</div>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Investment Dashboard')"
routerLink="/investment/dashboard"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="chart-pie" class="gl-icon"></fa-icon> Portfolio Dashboard
</a>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Due Date Diary for All Investment')"
routerLink="/investment/dashboard"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="calendar-check" class="gl-icon"></fa-icon> Due Date Diary for All Investment
</a>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Bucket Wise Investment')"
routerLink="/investment/reports"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="layer-group" class="gl-icon"></fa-icon> Bucket Wise Investment
</a>
</div>
<!-- Entry -->
<div class="gl-group">
<div class="gl-group-title">Entry</div>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('FD Investment')"
routerLink="/investment/fd"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="piggy-bank" class="gl-icon"></fa-icon> FD Investment
</a>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Govt Security / Non-SLR / Bond / Mutual Fund')"
routerLink="/investment/bonds"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="university" class="gl-icon"></fa-icon> Govt Security / Non-SLR / Bond / Mutual Fund
</a>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('SLR and Non SLR Investment Report')"
routerLink="/investment/bonds"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="balance-scale" class="gl-icon"></fa-icon> SLR and Non SLR Investment Report
</a>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Share Entry')"
routerLink="/investment/share"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="chart-line" class="gl-icon"></fa-icon> Share Entry
</a>
</div>
<!-- Masters -->
<div class="gl-group">
<div class="gl-group-title">Masters</div>
<a
class="gl-link"
[class.hidden]="
!matchesInvestmentSearch(
'Bank Party Script Broker SubType Control Parameter Daily Script LIEN Reason Mutual Fund Product SGL Bank Script Market Detail Masters'
)
"
routerLink="/investment/masters"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="cogs" class="gl-icon"></fa-icon> Bank/Party, Script, Broker &amp; Other Masters
</a>
</div>
<!-- Interest -->
<div class="gl-group">
<div class="gl-group-title">Interest</div>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('FD Interest Calculation')"
routerLink="/investment/interest"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="calculator" class="gl-icon"></fa-icon> FD Interest Calculation
</a>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Govt Bond Interest Calculation')"
routerLink="/investment/interest"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="calculator" class="gl-icon"></fa-icon> Govt./Bond Int. Calculation
</a>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Government Bond Int Register FD Interest Register FD Register')"
routerLink="/investment/interest"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="list-alt" class="gl-icon"></fa-icon> Bond Int / FD Int / FD Registers
</a>
</div>
<!-- Reports -->
<div class="gl-group">
<div class="gl-group-title">Reports</div>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Balance Ledger Maturity Investment Reports')"
routerLink="/investment/reports"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="file-invoice" class="gl-icon"></fa-icon> Balance / Ledger / Maturity Reports
</a>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Total Int Received & Receivable Report')"
routerLink="/investment/reports"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="hand-holding-usd" class="gl-icon"></fa-icon> Total Int Received &amp; Receivable Report
</a>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Investment Portfolio')"
routerLink="/investment/reports"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="briefcase" class="gl-icon"></fa-icon> Investment Portfolio
</a>
</div>
</div>
</div>
}
<!-- Progress Bar -->
@if (progressBarMode !== 'none') {
<div>
+14
View File
@@ -856,6 +856,20 @@ $gray-700: #334155;
}
}
// Marks a menu link as actually wired to a working screen/dialog (vs a still-unwired
// placeholder) — a small green dot at the end of the row, so a reviewer can tell at a glance
// which items in a mega-menu are functional without having to click every one of them.
.gl-link--live::after {
content: '';
width: 6px;
height: 6px;
margin-left: auto;
flex-shrink: 0;
border-radius: 50%;
background: #16a34a;
box-shadow: 0 0 0 2px rgb(22 163 74 / 15%);
}
/* RBI Mega Menu */
.rbi-mega-menu {
width: 1100px;
+33 -19
View File
@@ -98,6 +98,10 @@ export class ShellComponent implements OnInit, OnDestroy {
rbiMenuOpen = false;
rbiSearch = '';
/** Investment (Treasury) mega menu state */
investmentMenuOpen = false;
investmentSearch = '';
/* Live KPI ticker — markup is static inside the template; the marquee
animation, LIVE badge, and pause-on-hover handle the live feel. */
@@ -116,6 +120,11 @@ export class ShellComponent implements OnInit, OnDestroy {
return label.toLowerCase().includes(this.rbiSearch.toLowerCase());
}
matchesInvestmentSearch(label: string): boolean {
if (!this.investmentSearch) return true;
return label.toLowerCase().includes(this.investmentSearch.toLowerCase());
}
/** Route for a facility entry in the Account Facilities hover dropdown — only "Cheque Book" has a
* dedicated action page today; every other facility falls back to the existing overview page. */
facilityRoute(label: string): string[] {
@@ -242,7 +251,7 @@ export class ShellComponent implements OnInit, OnDestroy {
* GL Wise DR/CR / Misc Expense Ledger). Initial report selected via menu link.
*/
async openAccountingReportsDialog(
initialReport: 'PNL' | 'BALANCE-SHEET' | 'COMPARATIVE-BS' | 'GL-DR-CR' | 'MISC-EXP' = 'PNL'
initialReport: 'PNL' | 'BALANCE-SHEET' | 'COMPARATIVE-BS' | 'CONTINGENT-LIAB' | 'GL-DR-CR' | 'MISC-EXP' = 'PNL'
): Promise<void> {
const { AccountingReportsDialogComponent } =
await import('../../home/dashboard/accounting-reports-dialog/accounting-reports-dialog.component');
@@ -280,29 +289,14 @@ export class ShellComponent implements OnInit, OnDestroy {
});
}
/**
* Opens the CTR & STR Compliance Center dialog.
*/
async openCtrStrDialog(): Promise<void> {
const { CtrStrDialogComponent } = await import('../../home/dashboard/ctr-str-dialog/ctr-str-dialog.component');
this.dialog.open(CtrStrDialogComponent, {
width: '95vw',
maxWidth: '1280px',
maxHeight: '92vh',
panelClass: 'crr-slr-dialog-panel',
autoFocus: false,
disableClose: false,
hasBackdrop: true,
backdropClass: 'ckyc-rpt-dlg-backdrop'
});
}
/**
* Opens the CIMS / XBRL Returns dialog. The initial form (OSS-2, PSA, RAL, RCP-UCB)
* is selected based on the menu link the user clicked; user can switch between
* forms via the form-pill selector inside the dialog.
*/
async openCimsXbrlDialog(initialForm: 'OSS-2' | 'PSA' | 'RAL' | 'RCP-UCB' = 'OSS-2'): Promise<void> {
async openCimsXbrlDialog(
initialForm: 'OSS-2' | 'PSA' | 'RAL' | 'RCP-UCB' | 'OSS-4' | 'OSS-5' | 'OSS-6' | 'OSS-7' = 'OSS-2'
): Promise<void> {
const { CimsXbrlDialogComponent } =
await import('../../home/dashboard/cims-xbrl-dialog/cims-xbrl-dialog.component');
this.dialog.open(CimsXbrlDialogComponent, {
@@ -318,6 +312,26 @@ export class ShellComponent implements OnInit, OnDestroy {
});
}
/** Opens the FD / RD Entry dialog under Sub Ledger → Deposits. */
async openFdRdEntryDialog(): Promise<void> {
const { FdRdEntryDialogComponent } =
await import('../../home/dashboard/fd-rd-entry-dialog/fd-rd-entry-dialog.component');
this.dialog.open(FdRdEntryDialogComponent, {
width: '100vw',
height: '100vh',
maxWidth: '100vw',
maxHeight: '100vh',
panelClass: [
'crr-slr-dialog-panel',
'fdrd-fullscreen-panel'
],
autoFocus: false,
disableClose: false,
hasBackdrop: true,
backdropClass: 'fdrd-dlg-backdrop'
});
}
/**
* Opens the Asset Liability Management dialog (Form ALM-1).
* Covers maturity ladder, monthly snapshot, and structural liquidity returns.
@@ -266,7 +266,7 @@
<a
class="subledger-link"
[class.hidden]="!matchesSearch('Cash / Clearing / Transfer')"
(click)="subLedgerOpen = false"
(click)="navigateMenu('/cashier/cash-clearing-transfer-summary'); subLedgerOpen = false"
>
<fa-icon icon="money-bill" class="subledger-icon"></fa-icon> Cash / Clearing / Transfer
</a>
+3 -3
View File
@@ -81,7 +81,7 @@ export interface DeafEligibleAccount {
/** Form I — Dec 2025 cycle */
export const FORM_I: DeafForm = {
bankName: 'Bharat Urban Bank Ltd, Mumbai Main Branch',
bankName: 'CBSNexora Bank Ltd, Mumbai Main Branch',
bankDeafCode: '1100',
remittedThroughSponsor: 'Y',
sponsorBankName: 'State Bank of India',
@@ -170,7 +170,7 @@ export const FORM_I: DeafForm = {
/** Form II — Jan 2026 (head office consolidated) */
export const FORM_II: DeafForm = {
bankName: 'Bharat Urban Bank Ltd, Head Office',
bankName: 'CBSNexora Bank Ltd, Head Office',
bankDeafCode: '1100',
remittedThroughSponsor: 'Y',
sponsorBankName: 'State Bank of India',
@@ -271,7 +271,7 @@ export const REFUND_CLAIMS: {
auditor: string;
rows: DeafClaim[];
} = {
bankName: 'Bharat Urban Bank Ltd',
bankName: 'CBSNexora Bank Ltd',
bankDeafCode: '1100',
currentAcWithRbi: '03010200099001',
sponsorBank: 'State Bank of India',
+1 -1
View File
@@ -53,7 +53,7 @@ export interface SahayDepositor {
}
export const DICGC_BANK = {
name: 'Bharat Urban Bank Ltd',
name: 'CBSNexora Bank Ltd',
branch: 'Head Office',
reportAsOn: '29-01-2026',
printDate: '29-01-2026 04:19 PM',
+2 -2
View File
@@ -114,13 +114,13 @@ export interface KycEntity {
}
export const FIU_BANK = {
name: 'Bharat Urban Bank Ltd',
name: 'CBSNexora Bank Ltd',
branch: 'Head Office',
reBankingFlag: 'SCB',
reportingMonth: 'January 2026',
reBuid: 'REBANK-2031',
principalOfficer: 'Karthik Kamath',
principalOfficerEmail: 'po.fiu@bharaturbanbank.example',
principalOfficerEmail: 'po.fiu@cbsnexorabank.example',
filerUserId: 'PO-001',
cashThreshold: 1000000, // ₹10 L per single cash txn
monthlyAggThreshold: 1000000, // aggregate ≥ ₹10 L in a month
+221 -2
View File
@@ -129,9 +129,130 @@
<!-- CTR -->
@if (activeTab === 'ctr') {
<div class="fiu-section">
<!-- LIVE: cash-related AML alerts awaiting AML Officer review -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>CTR — Cash Transaction Report</h3>
<h3>Live Cash Alerts</h3>
<p>
Raised by the transaction-monitoring rule engine — review and whitelist or escalate to Principal Officer
</p>
</div>
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter" (ngModelChange)="loadAmlAlerts()">
<option value="">All statuses</option>
<option value="RAISED">Raised</option>
<option value="UNDER_REVIEW">Under Review</option>
<option value="WHITELISTED">Whitelisted</option>
<option value="ESCALATED">Escalated</option>
</select>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<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>Notes</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (alert of amlAlerts; 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>
@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="9" class="fiu-empty">No live alerts.</td>
</tr>
}
</tbody>
</table>
</div>
<!-- LIVE: monthly CTR reports (mechanical, threshold-based) -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>Monthly CTR Reports</h3>
<p>
Threshold: <strong>&#8377;{{ bank.cashThreshold | number }}</strong> per single txn / connected aggregate
per month
</p>
</div>
<div class="fiu-action-group">
<input class="fiu-inp" type="month" [(ngModel)]="ctrGenerateMonth" />
<button class="fiu-btn fiu-btn-g" *mifosxHasPermission="'REVIEW_AML_ALERT'" (click)="generateCtrReport()">
Generate
</button>
</div>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>Month</th>
<th>Generated On</th>
<th class="ta-c">Records</th>
<th>Status</th>
<th>Submitted On</th>
</tr>
</thead>
<tbody>
@for (r of amlCtrReports; track r.id) {
<tr>
<td>{{ r.reportMonth }}</td>
<td>{{ r.generatedOn | date: 'medium' }}</td>
<td class="ta-c">{{ r.recordCount }}</td>
<td>
<span [class]="amlStatusClass(r.status)">{{ r.status }}</span>
</td>
<td>{{ r.submittedOn ? (r.submittedOn | date: 'medium') : '—' }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="fiu-empty">No CTR reports generated yet.</td>
</tr>
}
</tbody>
</table>
</div>
<!-- FINnet export template (separate concern — TC1/TC2 file-format generation) -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>FINnet TC2 Export Template</h3>
<p>
All cash transactions ≥ &#8377;{{ bank.cashThreshold | number }} (single or aggregate per month per account)
</p>
@@ -221,9 +342,107 @@
<!-- STR -->
@if (activeTab === 'str') {
<div class="fiu-section">
<!-- LIVE: STR cases with full Principal Officer actions -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>STR — Suspicious Transaction Report</h3>
<h3>Live STR Cases</h3>
<p>
Escalated by an AML Officer; Principal Officer investigates, concludes and files with FIU-IND within 7
working days
</p>
</div>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>ID</th>
<th>Client</th>
<th class="ta-c">Cumulative Cases</th>
<th>Assigned PO</th>
<th>Conclusion</th>
<th>FIU Due Date</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (sc of amlStrCases; track sc.id) {
<tr [class.selected-row]="selectedStrCase?.id === sc.id">
<td>{{ sc.id }}</td>
<td>{{ sc.clientDisplayName }}</td>
<td class="ta-c" [class.escalation-warning]="sc.cumulativeStrCaseCountForClient >= 3">
{{ sc.cumulativeStrCaseCountForClient }}
</td>
<td>{{ sc.assignedPoUsername || '—' }}</td>
<td>{{ sc.conclusion || '—' }}</td>
<td>{{ sc.fiuDueDate || '—' }}</td>
<td>
<span [class]="amlStatusClass(sc.status)">{{ sc.status }}</span>
</td>
<td><button class="fiu-btn fiu-btn-g" (click)="selectStrCase(sc)">Investigate</button></td>
</tr>
} @empty {
<tr>
<td colspan="8" class="fiu-empty">No STR cases yet.</td>
</tr>
}
</tbody>
</table>
</div>
@if (selectedStrCase) {
<div class="str-case-detail">
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>STR Case #{{ selectedStrCase.id }} — {{ selectedStrCase.clientDisplayName }}</h3>
<p>
Alert #{{ selectedStrCase.alertId }} &middot;
{{ selectedStrCase.cumulativeStrCaseCountForClient }} cumulative STR case(s) for this client
@if (selectedStrCase.cumulativeStrCaseCountForClient >= 3) {
<span class="escalation-badge">Risk category auto-escalated</span>
}
</p>
</div>
<button class="fiu-btn fiu-btn-g" (click)="closeStrCaseDetail()">Close</button>
</div>
<div class="case-form" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<div class="form-row">
<label for="fiuInvestigationNotes">Investigation notes (source-of-transaction history reviewed)</label>
<textarea id="fiuInvestigationNotes" rows="3" [(ngModel)]="concludeInvestigationNotes"></textarea>
</div>
<div class="form-row">
<label for="fiuSourceOfFundsNotes">Source-of-funds notes</label>
<textarea id="fiuSourceOfFundsNotes" rows="2" [(ngModel)]="concludeSourceOfFundsNotes"></textarea>
</div>
<div class="form-row conclusion-row">
<label>
<input type="radio" name="fiuConclusion" value="NOT_SUSPICIOUS" [(ngModel)]="concludeConclusion" />
Not suspicious (whitelist)
</label>
<label>
<input type="radio" name="fiuConclusion" value="SUSPICIOUS" [(ngModel)]="concludeConclusion" />
Suspicious (file STR)
</label>
</div>
<button class="fiu-btn fiu-btn-xml" (click)="concludeStrCase()">Conclude</button>
@if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') {
<div class="file-str-row">
<label for="fiuFileReferenceNo">FIU-IND acknowledgement / reference number</label>
<input id="fiuFileReferenceNo" class="fiu-inp" type="text" [(ngModel)]="fileReferenceNo" />
<button class="fiu-btn fiu-btn-xml" (click)="fileStrCase()">Mark STR Filed</button>
</div>
}
</div>
</div>
}
<!-- FINnet export template (separate concern — STR XML file-format generation) -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>FINnet STR XML Export Template</h3>
<p>Detected by AML rules engine + branch compliance officers; filed within 7 days</p>
</div>
<button class="fiu-btn fiu-btn-xml" (click)="exportStrXml()">
+117
View File
@@ -579,6 +579,41 @@
background: var(--f-red-l);
color: var(--f-red);
}
&.status-raised {
background: var(--f-amber-l);
color: var(--f-amber);
}
&.status-under-review {
background: var(--f-blue-l);
color: var(--f-blue);
}
&.status-whitelisted,
&.status-concluded-clear,
&.status-generated,
&.status-submitted {
background: var(--f-green-l);
color: var(--f-green);
}
&.status-escalated,
&.status-open,
&.status-concluded-suspicious {
background: var(--f-red-l);
color: var(--f-red);
}
&.status-filed {
background: var(--f-purple-l);
color: var(--f-purple);
}
&.status-draft {
background: var(--f-g100);
color: var(--f-g500);
}
}
.risk-pill {
@@ -892,3 +927,85 @@
grid-template-columns: 1fr;
}
}
.selected-row {
background: var(--f-blue-l);
}
.escalation-warning {
color: var(--f-red);
font-weight: 700;
}
.escalation-badge {
display: inline-block;
margin-left: 8px;
padding: 2px 8px;
border-radius: 10px;
background: var(--f-amber-l);
color: var(--f-amber);
font-size: 11px;
font-weight: 600;
}
.str-case-detail {
border: 1px solid var(--f-g200);
border-radius: 10px;
padding: 16px;
background: #fff;
margin-bottom: 16px;
}
.case-form {
display: flex;
flex-direction: column;
gap: 12px;
textarea {
width: 100%;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--f-g300);
font-family: inherit;
font-size: 13px;
}
}
.form-row {
display: flex;
flex-direction: column;
gap: 4px;
label {
font-size: 12px;
font-weight: 600;
color: var(--f-g500);
}
}
.conclusion-row {
flex-direction: row;
gap: 24px;
label {
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
}
}
.file-str-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
padding-top: 12px;
border-top: 1px dashed var(--f-g300);
label {
font-size: 12px;
font-weight: 600;
color: var(--f-g500);
}
}
+126 -2
View File
@@ -6,10 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component } from '@angular/core';
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 { AmlService } from 'app/aml/aml.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import {
CtrTransaction,
CTR_TXNS,
@@ -33,7 +35,9 @@ import {
FormsModule
]
})
export class FiuComponent {
export class FiuComponent implements OnInit {
private amlService = inject(AmlService);
private authenticationService = inject(AuthenticationService);
/** Active tab */
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
@@ -57,6 +61,126 @@ export class FiuComponent {
toastVisible = false;
private toastTimer: any;
// -------------------- Live AML monitoring (real backend, replaces mock CTR/STR tables) --------------------
amlAlerts: any[] = [];
amlAlertStatusFilter = '';
amlReviewNotes: Record<number, string> = {};
amlCtrReports: any[] = [];
ctrGenerateMonth = '';
amlStrCases: any[] = [];
selectedStrCase: any = null;
concludeConclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' = 'NOT_SUSPICIOUS';
concludeInvestigationNotes = '';
concludeSourceOfFundsNotes = '';
fileReferenceNo = '';
get isPrincipalOfficer(): boolean {
return this.authenticationService.isPrincipalOfficerUser();
}
get isAmlOfficer(): boolean {
return this.authenticationService.isAmlOfficerUser();
}
ngOnInit(): void {
this.loadAmlAlerts();
this.loadAmlCtrReports();
this.loadAmlStrCases();
}
loadAmlAlerts(): void {
this.amlService
.getAlerts(this.amlAlertStatusFilter || undefined)
.subscribe((data) => (this.amlAlerts = data || []));
}
loadAmlCtrReports(): void {
this.amlService.getCtrReports().subscribe((data) => (this.amlCtrReports = data || []));
}
loadAmlStrCases(): void {
this.amlService.getStrCases().subscribe((data) => (this.amlStrCases = data || []));
}
whitelistAlert(alert: any): void {
this.amlService.reviewAlert(alert.id, 'WHITELIST', this.amlReviewNotes[alert.id] || '').subscribe(() => {
this.toast(`Alert #${alert.id} whitelisted`);
this.loadAmlAlerts();
});
}
escalateAlert(alert: any): void {
this.amlService.reviewAlert(alert.id, 'ESCALATE', this.amlReviewNotes[alert.id] || '').subscribe(() => {
this.toast(`Alert #${alert.id} escalated to Principal Officer`);
this.loadAmlAlerts();
this.loadAmlStrCases();
});
}
generateCtrReport(): void {
if (!this.ctrGenerateMonth) {
return;
}
this.amlService.generateCtrReport(this.ctrGenerateMonth).subscribe({
next: () => {
this.toast(`CTR report generated for ${this.ctrGenerateMonth}`);
this.loadAmlCtrReports();
},
error: (err) => this.toast(err?.error?.defaultUserMessage || 'CTR report generation failed')
});
}
selectStrCase(strCase: any): void {
this.selectedStrCase = strCase;
this.concludeConclusion = 'NOT_SUSPICIOUS';
this.concludeInvestigationNotes = strCase.investigationNotes || '';
this.concludeSourceOfFundsNotes = strCase.sourceOfFundsNotes || '';
this.fileReferenceNo = '';
}
closeStrCaseDetail(): void {
this.selectedStrCase = null;
}
concludeStrCase(): void {
if (!this.selectedStrCase) {
return;
}
this.amlService
.concludeStrCase(
this.selectedStrCase.id,
this.concludeConclusion,
this.concludeInvestigationNotes,
this.concludeSourceOfFundsNotes
)
.subscribe((updated) => {
this.toast(
this.concludeConclusion === 'SUSPICIOUS'
? `STR case #${updated.id} concluded suspicious — FIU due date ${updated.fiuDueDate}`
: `STR case #${updated.id} concluded not suspicious`
);
this.selectedStrCase = updated;
this.loadAmlStrCases();
});
}
fileStrCase(): void {
if (!this.selectedStrCase || !this.fileReferenceNo) {
return;
}
this.amlService.fileStr(this.selectedStrCase.id, this.fileReferenceNo).subscribe((updated) => {
this.toast(`STR case #${updated.id} recorded as filed (ref ${updated.strReferenceNo})`);
this.selectedStrCase = updated;
this.loadAmlStrCases();
});
}
amlStatusClass(status: string): string {
return 'status-pill status-' + (status || '').toLowerCase().replace(/_/g, '-');
}
showTab(tab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files'): void {
this.activeTab = tab;
}
@@ -397,6 +397,64 @@
</div>
</ng-container>
<!-- ════════════ CONTINGENT LIABILITIES ════════════ -->
<ng-container *ngIf="activeReport === 'CONTINGENT-LIAB'">
<div class="acr-stats">
<div class="acr-stat-card alert">
<div class="acr-stat-l">Total Contingent Liabilities — {{ bank.fiscalYear }}</div>
<div class="acr-stat-v">&#8377; {{ contingentLiab.current | number: '1.2-2' }} Lakh</div>
<div class="acr-stat-meta">vs {{ contingentLiab.previous | number: '1.2-2' }} Lakh (PY)</div>
</div>
<div class="acr-stat-card" [ngClass]="clVariation <= 0 ? 'good' : 'alert'">
<div class="acr-stat-l">Variation over Previous Year</div>
<div class="acr-stat-v">{{ clVariation >= 0 ? '+' : '' }}{{ clVariation | number: '1.2-2' }} Lakh</div>
<div class="acr-stat-meta">{{ clVariationPct >= 0 ? '+' : '' }}{{ clVariationPct.toFixed(2) }}%</div>
</div>
<div class="acr-stat-card">
<div class="acr-stat-l">As On Date</div>
<div class="acr-stat-v" style="font-size: 16px">{{ bank.asOnDate }}</div>
<div class="acr-stat-meta">Schedule 12</div>
</div>
</div>
<div class="acr-section">
<div class="acr-sec-h">
<h3 class="acr-sec-t">Contingent Liabilities &mdash; As on {{ bank.asOnDate }} (₹ in Lakhs)</h3>
<button mat-stroked-button class="acr-btn-xls" (click)="exportContingentLiabCsv()">
<fa-icon icon="file-csv"></fa-icon>&nbsp; Export CSV
</button>
</div>
<div class="acr-tbl-wrap">
<table class="acr-tbl">
<thead>
<tr>
<th colspan="2" class="acr-sub-h">Items Covered Under This Schedule</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of contingentLiabItems">
<td class="mono">{{ item.code }}</td>
<td>{{ item.label }}</td>
</tr>
</tbody>
<tfoot>
<tr class="row-total expense">
<td colspan="2">
<strong>Total Contingent Liabilities: &#8377; {{ contingentLiab.current | number: '1.2-2' }} Lakh</strong>
</td>
</tr>
</tfoot>
</table>
</div>
<p class="acr-note">
<fa-icon icon="info-circle"></fa-icon>&nbsp; Individual item-wise figures for this schedule are not broken
out here — only the audited total is shown, to avoid presenting a sub-item split that could not be
independently verified against the source statement.
</p>
</div>
</ng-container>
<!-- ════════════ GL DR/CR ════════════ -->
<ng-container *ngIf="activeReport === 'GL-DR-CR'">
<div class="acr-stats">
@@ -318,6 +318,16 @@
border-bottom: 1px dashed var(--acr-g200);
}
.acr-note {
margin: 12px 0 0;
font-size: 12px;
font-style: italic;
color: var(--acr-g500);
display: flex;
align-items: flex-start;
gap: 6px;
}
.acr-btn-xls {
font-size: 12px !important;
height: 34px !important;
@@ -14,7 +14,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
export type AcctReportCode = 'PNL' | 'BALANCE-SHEET' | 'COMPARATIVE-BS' | 'GL-DR-CR' | 'MISC-EXP';
export type AcctReportCode = 'PNL' | 'BALANCE-SHEET' | 'COMPARATIVE-BS' | 'CONTINGENT-LIAB' | 'GL-DR-CR' | 'MISC-EXP';
export interface AcctReportsDialogData {
initialReport?: AcctReportCode;
@@ -47,6 +47,11 @@ interface GlLine {
closing: number;
}
interface ContingentLiabItem {
code: string;
label: string;
}
interface MiscExpRow {
txnDate: string;
branch: string;
@@ -81,7 +86,7 @@ export class AccountingReportsDialogComponent {
activeReport: AcctReportCode;
bank = {
name: 'Bharat Urban Bank Ltd',
name: 'CBSNexora Bank Ltd',
branch: 'Head Office',
asOnDate: '31-03-2026',
period: '01-04-2025 to 31-03-2026',
@@ -94,7 +99,7 @@ export class AccountingReportsDialogComponent {
poDesignation: 'Chief Finance Officer',
auditor: 'M/s Sharma & Associates, Chartered Accountants',
reportRefNo: '157/2',
userCode: 'BUBL01',
userCode: 'CBSN01',
branchCount: 15
};
@@ -120,6 +125,13 @@ export class AccountingReportsDialogComponent {
description: 'Side-by-side comparison of current vs previous fiscal year.',
group: 'Financial Statement'
},
{
code: 'CONTINGENT-LIAB' as AcctReportCode,
name: 'Contingent Liabilities',
icon: 'exclamation-triangle',
description: 'Off-balance-sheet claims, guarantees & contingent exposures (Schedule 12).',
group: 'Financial Statement'
},
{
code: 'GL-DR-CR' as AcctReportCode,
name: 'GL Wise DR/CR',
@@ -136,309 +148,192 @@ export class AccountingReportsDialogComponent {
}
];
// -------------------- P&L Data (₹ in Lakhs) --------------------
// -------------------- P&L Data (₹ in Lakhs) — FY 2025-26 audited figures --------------------
pnl: PnlLine[] = [
{ code: 'I-01', group: 'INCOME', subgroup: 'Interest Earned', label: 'Interest on Advances', amount: 38420 },
{ code: 'I-02', group: 'INCOME', subgroup: 'Interest Earned', label: 'Interest on Investments', amount: 14860 },
{ code: 'I-01', group: 'INCOME', subgroup: 'Interest Earned', label: 'Interest on Advances & Bills', amount: 102641.73 },
{ code: 'I-02', group: 'INCOME', subgroup: 'Interest Earned', label: 'Income on Investments', amount: 41936.75 },
{
code: 'I-03',
group: 'INCOME',
subgroup: 'Interest Earned',
label: 'Interest on Balances with RBI / Other Banks',
amount: 1240
},
{
code: 'I-04',
group: 'INCOME',
subgroup: 'Other Income',
label: 'Commission, Exchange & Brokerage',
amount: 1820
amount: 144.2
},
{ code: 'I-04', group: 'INCOME', subgroup: 'Other Income', label: 'Dividend Income', amount: 4.11 },
{
code: 'I-05',
group: 'INCOME',
subgroup: 'Other Income',
label: 'Net Income on Sale of Fixed Assets',
amount: 0.3
},
{ code: 'I-06', group: 'INCOME', subgroup: 'Other Income', label: 'Other Income (ATM/POS/UPI/Rental/Misc)', amount: 280 },
{
code: 'E-01',
group: 'EXPENDITURE',
subgroup: 'Interest Expended',
label: 'Interest Paid on Deposits & Other Borrowings',
amount: 62861.8
},
{ code: 'I-05', group: 'INCOME', subgroup: 'Other Income', label: 'Profit on Sale of Investments', amount: 480 },
{ code: 'I-06', group: 'INCOME', subgroup: 'Other Income', label: 'Dividend Income', amount: 142 },
{ code: 'I-07', group: 'INCOME', subgroup: 'Other Income', label: 'Miscellaneous Income', amount: 318 },
{ code: 'E-01', group: 'EXPENDITURE', subgroup: 'Interest Expended', label: 'Interest on Deposits', amount: 28640 },
{
code: 'E-02',
group: 'EXPENDITURE',
subgroup: 'Interest Expended',
label: 'Interest on RBI / Inter-Bank Borrowings',
amount: 980
label: 'Interest Paid on NABARD Borrowings',
amount: 47083.71
},
{
code: 'E-03',
group: 'EXPENDITURE',
subgroup: 'Operating Expenses',
label: 'Payments to & Provisions for Employees',
amount: 8420
label: 'Operating Expenses (Staff, Admin, Establishment)',
amount: 14503.79
},
{
code: 'E-04',
group: 'EXPENDITURE',
subgroup: 'Operating Expenses',
label: 'Rent, Taxes & Lighting',
amount: 1240
subgroup: 'Provisions',
label: 'Provision for Standard Assets',
amount: 730
},
{
code: 'E-05',
group: 'EXPENDITURE',
subgroup: 'Provisions',
label: 'Provision for Doubtful & Bad Debts (Loans & Advances)',
amount: 2068.2
},
{ code: 'E-05', group: 'EXPENDITURE', subgroup: 'Operating Expenses', label: 'Printing & Stationery', amount: 184 },
{
code: 'E-06',
group: 'EXPENDITURE',
subgroup: 'Operating Expenses',
label: 'Depreciation on Bank Property',
amount: 642
subgroup: 'Provisions',
label: 'Bad Debts Written Off',
amount: 906.44
},
{
code: 'E-07',
group: 'EXPENDITURE',
subgroup: 'Operating Expenses',
label: 'Auditors Fees & Expenses',
amount: 124
},
{
code: 'E-08',
group: 'EXPENDITURE',
subgroup: 'Operating Expenses',
label: 'Repairs, Maintenance & Insurance',
amount: 386
},
{
code: 'E-09',
group: 'EXPENDITURE',
subgroup: 'Operating Expenses',
label: 'Other Operating Expenses',
amount: 824
},
{
code: 'E-10',
group: 'EXPENDITURE',
subgroup: 'Provisions',
label: 'Provisions for NPA / Standard Assets',
amount: 4280
label: 'Provision for Staff Training Reserve',
amount: 245.06
},
{ code: 'E-11', group: 'EXPENDITURE', subgroup: 'Provisions', label: 'Provisions for Tax', amount: 2120 }
{ code: 'E-08', group: 'EXPENDITURE', subgroup: 'Provisions', label: 'Provision for Income Tax', amount: 4600 }
];
// -------------------- Balance Sheet (₹ in Lakhs) --------------------
// -------------------- Balance Sheet (₹ in Lakhs) — as on 31-03-2026, audited --------------------
bs: BsLine[] = [
{
code: 'L-01',
side: 'CAPITAL-LIAB',
group: 'Capital & Reserves',
label: 'Capital (Paid-up Share Capital)',
amount: 22000,
prevAmount: 20800
amount: 92837.18,
prevAmount: 88842.87
},
{
code: 'L-02',
side: 'CAPITAL-LIAB',
group: 'Capital & Reserves',
label: 'Reserves & Surplus',
amount: 32400,
prevAmount: 28640
amount: 155847.97,
prevAmount: 145524.37
},
{
code: 'L-03',
side: 'CAPITAL-LIAB',
group: 'Deposits',
label: 'Demand Deposits — Current Accounts',
amount: 84200,
prevAmount: 78420
label: 'Deposits',
amount: 1154054.96,
prevAmount: 997239.51
},
{
code: 'L-04',
side: 'CAPITAL-LIAB',
group: 'Deposits',
label: 'Savings Bank Deposits',
amount: 218600,
prevAmount: 198400
group: 'Borrowings',
label: 'Borrowings (NABARD / Other Banks)',
amount: 1113954.48,
prevAmount: 993301.8
},
{
code: 'L-05',
side: 'CAPITAL-LIAB',
group: 'Deposits',
label: 'Term Deposits',
amount: 462800,
prevAmount: 412400
},
{
code: 'L-06',
side: 'CAPITAL-LIAB',
group: 'Borrowings',
label: 'Borrowings from RBI / NABARD',
amount: 14180,
prevAmount: 16240
},
{
code: 'L-07',
side: 'CAPITAL-LIAB',
group: 'Borrowings',
label: 'Other Borrowings',
amount: 10000,
prevAmount: 9420
},
{
code: 'L-08',
side: 'CAPITAL-LIAB',
group: 'Other Liabilities',
label: 'Bills Payable',
amount: 4420,
prevAmount: 3980
},
{
code: 'L-09',
side: 'CAPITAL-LIAB',
group: 'Other Liabilities',
label: 'Inter-Office Adjustments (Net)',
amount: 1820,
prevAmount: 1640
},
{
code: 'L-10',
side: 'CAPITAL-LIAB',
group: 'Other Liabilities',
label: 'Interest Accrued',
amount: 6240,
prevAmount: 5840
},
{
code: 'L-11',
side: 'CAPITAL-LIAB',
group: 'Other Liabilities',
label: 'Other Liabilities & Provisions',
amount: 5940,
prevAmount: 5420
amount: 121037.61,
prevAmount: 124706.3
},
{
code: 'A-01',
side: 'ASSETS',
group: 'Cash & Bank Balances',
label: 'Cash on Hand',
amount: 8420,
prevAmount: 7820
label: 'Cash & Balances with RBI',
amount: 32627.16,
prevAmount: 33361.93
},
{
code: 'A-02',
side: 'ASSETS',
group: 'Cash & Bank Balances',
label: 'Balances with RBI',
amount: 38400,
prevAmount: 34200
group: 'Money at Call',
label: 'Balances with Other Banks & Money at Call/Short Notice',
amount: 604619.45,
prevAmount: 549599.47
},
{
code: 'A-03',
side: 'ASSETS',
group: 'Cash & Bank Balances',
label: 'Balances with Other Banks — Current',
amount: 14800,
prevAmount: 12400
group: 'Investments',
label: 'Investments',
amount: 316953.61,
prevAmount: 248274.61
},
{
code: 'A-04',
side: 'ASSETS',
group: 'Cash & Bank Balances',
label: 'Balances with Other Banks — Term Deposits',
amount: 10000,
prevAmount: 9200
group: 'Advances',
label: 'Loans & Advances',
amount: 1627711.4,
prevAmount: 1466401.79
},
{
code: 'A-05',
side: 'ASSETS',
group: 'Money at Call',
label: 'Money at Call & Short Notice',
amount: 12400,
prevAmount: 10800
group: 'Fixed Assets',
label: 'Fixed Assets (Property, Plant & Equipment)',
amount: 33791.75,
prevAmount: 33727.66
},
{
code: 'A-06',
side: 'ASSETS',
group: 'Investments',
label: 'Government Securities (SLR)',
amount: 142400,
prevAmount: 128600
},
{
code: 'A-07',
side: 'ASSETS',
group: 'Investments',
label: 'Other Approved Securities',
amount: 28200,
prevAmount: 24800
},
{
code: 'A-08',
side: 'ASSETS',
group: 'Investments',
label: 'Shares / Debentures / Bonds',
amount: 14000,
prevAmount: 12200
},
{
code: 'A-09',
side: 'ASSETS',
group: 'Advances',
label: 'Cash Credit, Overdraft & Demand Loans',
amount: 168400,
prevAmount: 152800
},
{
code: 'A-10',
side: 'ASSETS',
group: 'Advances',
label: 'Term Loans (Secured)',
amount: 217865,
prevAmount: 198400
},
{
code: 'A-11',
side: 'ASSETS',
group: 'Advances',
label: 'Term Loans (Unsecured)',
amount: 30000,
prevAmount: 27200
},
{
code: 'A-12',
side: 'ASSETS',
group: 'Fixed Assets',
label: 'Premises (Net of Depreciation)',
amount: 9420,
prevAmount: 8820
},
{
code: 'A-13',
side: 'ASSETS',
group: 'Fixed Assets',
label: 'Other Fixed Assets',
amount: 4780,
prevAmount: 4620
},
{
code: 'A-14',
side: 'ASSETS',
group: 'Other Assets',
label: 'Inter-Office Adjustments (Net)',
amount: 0,
prevAmount: 0
},
{
code: 'A-15',
side: 'ASSETS',
group: 'Other Assets',
label: 'Tax Paid in Advance & TDS',
amount: 5420,
prevAmount: 4820
},
{ code: 'A-16', side: 'ASSETS', group: 'Other Assets', label: 'Stationery & Stamps', amount: 184, prevAmount: 142 },
{
code: 'A-17',
side: 'ASSETS',
group: 'Other Assets',
label: 'Sundry Debtors & Other Assets',
amount: 2876,
prevAmount: 2540
label: 'Other Assets',
amount: 22028.83,
prevAmount: 18249.4
}
];
// -------------------- Contingent Liabilities (Schedule 12, ₹ in Lakhs) --------------------
// Only the audited TOTAL is reported here — the schedule's individual line items (claims not
// acknowledged as debts, letter of guarantees, DEAF transfers, gratuity/RBI-penalty/GST disputes)
// could not be re-verified against the source figures with confidence, so they are omitted
// rather than risk showing an unverified breakdown.
contingentLiabItems: ContingentLiabItem[] = [
{ code: 'I', label: 'Claims against the bank not acknowledged as debts' },
{ code: 'II', label: 'Letter of Guarantees' },
{ code: 'III', label: 'Amounts transferred to Depositor Education and Awareness Fund (DEAF)' },
{ code: 'IV', label: 'Other items for which the bank is contingently liable (Gratuity / RBI Penalty / GST Demand)' }
];
contingentLiab = {
current: 1633.3,
previous: 1522.13
};
get clVariation(): number {
return this.variation(this.contingentLiab.current, this.contingentLiab.previous);
}
get clVariationPct(): number {
return this.variationPct(this.contingentLiab.current, this.contingentLiab.previous);
}
// -------------------- GL Wise DR/CR Data --------------------
glReport: GlLine[] = [
{
@@ -1008,6 +903,7 @@ export class AccountingReportsDialogComponent {
if (this.activeReport === 'PNL') return this.exportPnlCsv();
if (this.activeReport === 'BALANCE-SHEET') return this.exportBsCsv(false);
if (this.activeReport === 'COMPARATIVE-BS') return this.exportBsCsv(true);
if (this.activeReport === 'CONTINGENT-LIAB') return this.exportContingentLiabCsv();
if (this.activeReport === 'GL-DR-CR') return this.exportGlCsv();
if (this.activeReport === 'MISC-EXP') return this.exportMiscCsv();
}
@@ -1155,6 +1051,49 @@ export class AccountingReportsDialogComponent {
this.saveCsv(rows, `${comparative ? 'Comparative-' : ''}Balance-Sheet-${this.dateTag()}.csv`);
}
exportContingentLiabCsv(): void {
const rows: string[][] = [];
rows.push([
this.bank.name,
this.bank.branch
]);
rows.push([
'CONTINGENT LIABILITIES (SCHEDULE 12)',
`As on ${this.bank.asOnDate}`
]);
rows.push([]);
rows.push(['Items covered:']);
for (const item of this.contingentLiabItems) {
rows.push([
item.code,
item.label
]);
}
rows.push([]);
rows.push([
'',
'TOTAL CONTINGENT LIABILITIES',
`FY ${this.bank.fiscalYear} (₹ Lakh)`,
`FY ${this.bank.prevFiscalYear} (₹ Lakh)`,
'Variation',
'Variation %'
]);
rows.push([
'',
'',
this.contingentLiab.current.toFixed(2),
this.contingentLiab.previous.toFixed(2),
this.clVariation.toFixed(2),
this.clVariationPct.toFixed(2) + '%'
]);
rows.push([]);
rows.push([
'Certified by',
this.bank.principalOfficer + ' — ' + this.bank.poDesignation
]);
this.saveCsv(rows, `Contingent-Liabilities-${this.dateTag()}.csv`);
}
exportGlCsv(): void {
const rows: string[][] = [];
rows.push([
@@ -54,7 +54,7 @@ export class AlmReportDialogComponent {
activeTab: 'dashboard' | 'ladder' | 'monthly' | 'returns' = 'dashboard';
bank = {
name: 'Bharat Urban Bank Ltd',
name: 'CBSNexora Bank Ltd',
branch: 'Head Office',
asOnDate: '31-03-2026',
quarter: 'Q4 FY 2025-26',
@@ -63,7 +63,7 @@ export class AlmReportDialogComponent {
principalOfficer: 'Karthik Kamath',
formCode: 'Form ALM-1',
reportRefNo: '1036',
userCode: 'BUBL01',
userCode: 'CBSN01',
/** Net Demand & Time Liabilities — denominator for ratios */
ndtl: 740000, // ₹7,400 Cr in Lakhs
/** RBI tolerance: cumulative negative gap up to 14 days ≤ 5%, 28 days ≤ 10% */
@@ -14,7 +14,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
export type CimsFormCode = 'OSS-2' | 'PSA' | 'RAL' | 'RCP-UCB';
export type CimsFormCode = 'OSS-2' | 'PSA' | 'RAL' | 'RCP-UCB' | 'OSS-4' | 'OSS-5' | 'OSS-6' | 'OSS-7';
export interface CimsXbrlDialogData {
initialForm?: CimsFormCode;
@@ -64,20 +64,20 @@ export class CimsXbrlDialogComponent {
activeTab: 'filing' | 'data' | 'preview' = 'filing';
bank = {
name: 'Bharat Urban Bank Ltd',
name: 'CBSNexora Bank Ltd',
branch: 'Head Office',
bsrCode: 'BUBL0000123',
ifsc: 'BUBL0000123',
legalEntityId: 'BUBL-LEI-2025',
bsrCode: 'CBSN0000123',
ifsc: 'CBSN0000123',
legalEntityId: 'CBSN-LEI-2025',
pan: 'AAACB1234L',
asOnDate: '31-03-2026',
quarter: 'Q4 FY 2025-26',
fiscalYear: '2025-26',
principalOfficer: 'Karthik Kamath',
poDesignation: 'Chief Compliance Officer',
poEmail: 'compliance@bharaturbanbank.in',
poEmail: 'compliance@cbsnexorabank.in',
poMobile: '+91-9988002211',
cimsUserId: 'BUBL_CIMS_001',
cimsUserId: 'CBSN_CIMS_001',
cimsPortal: 'https://cims.rbi.org.in/'
};
@@ -97,7 +97,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:CapAdq.CRAR',
section: 'Capital Adequacy',
label: 'CRAR (Total)',
value: 14.82,
value: 21.82,
unit: 'Percent',
type: 'percent'
},
@@ -105,7 +105,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:CapAdq.Tier1',
section: 'Capital Adequacy',
label: 'Tier-I CRAR',
value: 12.18,
value: 20.51,
unit: 'Percent',
type: 'percent'
},
@@ -113,7 +113,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:CapAdq.Tier2',
section: 'Capital Adequacy',
label: 'Tier-II CRAR',
value: 2.64,
value: 1.31,
unit: 'Percent',
type: 'percent'
},
@@ -121,7 +121,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.Total',
section: 'Asset Classification',
label: 'Total Advances (₹ Lakh)',
value: 416265,
value: 4112.04,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -129,7 +129,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.Standard',
section: 'Asset Classification',
label: 'Standard Assets (₹ Lakh)',
value: 401820,
value: 3845.88,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -137,7 +137,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.SubStd',
section: 'Asset Classification',
label: 'Sub-Standard (₹ Lakh)',
value: 6220,
value: 102.41,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -145,7 +145,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.Doubtful',
section: 'Asset Classification',
label: 'Doubtful (₹ Lakh)',
value: 5840,
value: 163.75,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -153,7 +153,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.Loss',
section: 'Asset Classification',
label: 'Loss (₹ Lakh)',
value: 2385,
value: 0,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -161,7 +161,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.GNPA',
section: 'NPA Position',
label: 'Gross NPA (₹ Lakh)',
value: 14445,
value: 266.16,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -169,7 +169,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.NNPA',
section: 'NPA Position',
label: 'Net NPA (₹ Lakh)',
value: 5212,
value: -96.33,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -177,7 +177,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.GNPAPct',
section: 'NPA Position',
label: 'GNPA %',
value: 3.47,
value: 6.47,
unit: 'Percent',
type: 'percent'
},
@@ -185,7 +185,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.NNPAPct',
section: 'NPA Position',
label: 'NNPA %',
value: 1.27,
value: -2.6,
unit: 'Percent',
type: 'percent'
},
@@ -193,7 +193,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.PCR',
section: 'NPA Position',
label: 'Provision Coverage Ratio',
value: 64.13,
value: 136.24,
unit: 'Percent',
type: 'percent'
},
@@ -201,7 +201,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Profit.NIM',
section: 'Profitability',
label: 'Net Interest Margin',
value: 3.21,
value: 0.51,
unit: 'Percent',
type: 'percent'
},
@@ -209,7 +209,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Profit.ROA',
section: 'Profitability',
label: 'Return on Assets',
value: 0.92,
value: 0.67,
unit: 'Percent',
type: 'percent'
},
@@ -217,7 +217,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Profit.ROE',
section: 'Profitability',
label: 'Return on Equity',
value: 11.48,
value: 5.38,
unit: 'Percent',
type: 'percent'
},
@@ -254,7 +254,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:Reference.ANBC',
section: 'Reference',
label: 'Adjusted Net Bank Credit (₹ Lakh)',
value: 600000,
value: 3274.83,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -262,7 +262,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:Reference.OBE',
section: 'Reference',
label: 'Credit Equivalent of OBE (₹ Lakh)',
value: 25000,
value: 0,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -270,7 +270,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.Total',
section: 'PSL Achievement',
label: 'Total PSL Outstanding (₹ Lakh)',
value: 241700,
value: 2485.67,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -278,7 +278,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.TotalPct',
section: 'PSL Achievement',
label: 'Total PSL % of ANBC',
value: 40.28,
value: 75.9,
unit: 'Percent',
type: 'percent'
},
@@ -286,7 +286,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.Agri',
section: 'Sub-targets',
label: 'Agriculture (₹ Lakh)',
value: 124000,
value: 39.0,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -294,7 +294,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.AgriPct',
section: 'Sub-targets',
label: 'Agriculture % of ANBC',
value: 20.67,
value: 1.19,
unit: 'Percent',
type: 'percent'
},
@@ -302,7 +302,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.SMF',
section: 'Sub-targets',
label: 'Small &amp; Marginal Farmers (₹ Lakh)',
value: 72000,
value: 0,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -310,23 +310,23 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.SMFPct',
section: 'Sub-targets',
label: 'SMF % of ANBC',
value: 12.0,
value: 0,
unit: 'Percent',
type: 'percent'
},
{
ref: 'psa:PSL.MicroEnt',
section: 'Sub-targets',
label: 'Micro Enterprises (₹ Lakh)',
value: 51000,
label: 'MSME Enterprises (₹ Lakh)',
value: 2313.83,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'psa:PSL.MicroPct',
section: 'Sub-targets',
label: 'Micro Enterprises % of ANBC',
value: 8.5,
label: 'MSME Enterprises % of ANBC',
value: 70.65,
unit: 'Percent',
type: 'percent'
},
@@ -334,7 +334,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.Weaker',
section: 'Sub-targets',
label: 'Weaker Sections (₹ Lakh)',
value: 85500,
value: 990.28,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -342,7 +342,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.WeakerPct',
section: 'Sub-targets',
label: 'Weaker Sections % of ANBC',
value: 14.25,
value: 30.22,
unit: 'Percent',
type: 'percent'
},
@@ -350,7 +350,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.Housing',
section: 'Other Categories',
label: 'Housing (₹ Lakh)',
value: 9200,
value: 115.12,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -358,7 +358,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.Education',
section: 'Other Categories',
label: 'Education (₹ Lakh)',
value: 4200,
value: 17.32,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -366,7 +366,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.Export',
section: 'Other Categories',
label: 'Export Credit (₹ Lakh)',
value: 3800,
value: 0,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -374,7 +374,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.RenEnergy',
section: 'Other Categories',
label: 'Renewable Energy (₹ Lakh)',
value: 2400,
value: 0,
unit: 'INR-Lakh',
type: 'monetary'
}
@@ -395,15 +395,15 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Liab.Capital',
section: 'Liabilities',
label: 'Capital (₹ Lakh)',
value: 22000,
value: 272.78,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'ral:Liab.Reserves',
section: 'Liabilities',
label: 'Reserves &amp; Surplus (₹ Lakh)',
value: 32400,
label: 'Reserves, Surplus &amp; Profit (₹ Lakh)',
value: 712.6,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -411,7 +411,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Liab.DepDemand',
section: 'Liabilities',
label: 'Demand Deposits (₹ Lakh)',
value: 84200,
value: 195.92,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -419,7 +419,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Liab.DepSavings',
section: 'Liabilities',
label: 'Savings Deposits (₹ Lakh)',
value: 218600,
value: 2081.16,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -427,7 +427,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Liab.DepTerm',
section: 'Liabilities',
label: 'Term Deposits (₹ Lakh)',
value: 462800,
value: 4066.01,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -435,7 +435,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Liab.Borrow',
section: 'Liabilities',
label: 'Borrowings (₹ Lakh)',
value: 24180,
value: 0,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -443,7 +443,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Liab.Other',
section: 'Liabilities',
label: 'Other Liabilities &amp; Provisions (₹ Lakh)',
value: 18420,
value: 780.73,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -451,7 +451,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Asset.Cash',
section: 'Assets',
label: 'Cash on Hand (₹ Lakh)',
value: 8420,
value: 99.96,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -459,15 +459,15 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Asset.RBI',
section: 'Assets',
label: 'Balances with RBI (₹ Lakh)',
value: 38400,
value: 0,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'ral:Asset.Banks',
section: 'Assets',
label: 'Balances with Banks (₹ Lakh)',
value: 24800,
label: 'Balances with Banks &amp; Other Institutions (₹ Lakh)',
value: 1349.53,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -475,7 +475,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Asset.Money',
section: 'Assets',
label: 'Money at Call &amp; Short Notice (₹ Lakh)',
value: 12400,
value: 0,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -483,7 +483,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Asset.Invest',
section: 'Assets',
label: 'Investments (₹ Lakh)',
value: 184600,
value: 2342.97,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -491,7 +491,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Asset.Adv',
section: 'Assets',
label: 'Advances (₹ Lakh)',
value: 416265,
value: 3912.88,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -499,23 +499,23 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Asset.Fixed',
section: 'Assets',
label: 'Fixed Assets (₹ Lakh)',
value: 14200,
value: 49.48,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'ral:Asset.Other',
section: 'Assets',
label: 'Other Assets (₹ Lakh)',
value: 12480,
label: 'Other Assets &amp; Interest Receivable (₹ Lakh)',
value: 354.38,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'ral:OBE.Guar',
section: 'Off-Balance Sheet',
label: 'Bank Guarantees (₹ Lakh)',
value: 18420,
label: 'Contingent Liabilities (₹ Lakh)',
value: 26.38,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -523,7 +523,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:OBE.LC',
section: 'Off-Balance Sheet',
label: 'Letters of Credit (₹ Lakh)',
value: 6580,
value: 0,
unit: 'INR-Lakh',
type: 'monetary'
}
@@ -653,6 +653,293 @@ export class CimsXbrlDialogComponent {
type: 'monetary'
}
]
},
'OSS-4': {
code: 'OSS-4',
name: 'OSS-4',
fullName: 'Connected Lending &amp; Large Exposure Statement',
description:
'Quarterly statement of loans to directors/relatives, senior officers, and exposures exceeding prudential thresholds. Illustrative figures — no source filing was provided for this return; values are modelled on this bank\'s own capital/advances scale for consistency.',
cimsCode: 'CIMS_OSS_004',
taxonomy: 'rbi-cims-oss4-2024-04-01',
periodicity: 'Quarterly',
lastFiled: '15-01-2026',
nextDue: '15-04-2026',
fields: [
{
ref: 'oss4:Connected.Directors',
section: 'Connected Lending',
label: 'Loans to Directors &amp; Relatives (₹ Lakh)',
value: 42.5,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'oss4:Connected.SrOfficers',
section: 'Connected Lending',
label: 'Loans to Senior Officers (₹ Lakh)',
value: 18.75,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'oss4:Connected.Accounts',
section: 'Connected Lending',
label: 'Number of Connected Lending Accounts',
value: 12,
unit: 'Number',
type: 'numeric'
},
{
ref: 'oss4:LargeExp.Amount',
section: 'Large Exposures',
label: 'Exposures &gt; 10% of Capital Funds (₹ Lakh)',
value: 285.0,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'oss4:LargeExp.Pct',
section: 'Large Exposures',
label: 'Large Exposures as % of Capital Funds',
value: 30.58,
unit: 'Percent',
type: 'percent'
},
{
ref: 'oss4:LargeExp.SingleBreach',
section: 'Large Exposures',
label: 'Single Borrower Limit Breaches',
value: 0,
unit: 'Number',
type: 'numeric'
},
{
ref: 'oss4:LargeExp.GroupBreach',
section: 'Large Exposures',
label: 'Group Borrower Limit Breaches',
value: 0,
unit: 'Number',
type: 'numeric'
}
]
},
'OSS-5': {
code: 'OSS-5',
name: 'OSS-5',
fullName: 'Institutional &amp; Membership Profile',
description:
'Annual statement of branch network, staff strength, membership and depositor/borrower base. Illustrative figures — no source filing was provided for this return; membership share capital is aligned to this bank\'s real paid-up capital figure.',
cimsCode: 'CIMS_OSS_005',
taxonomy: 'rbi-cims-oss5-2024-04-01',
periodicity: 'Annual',
lastFiled: '30-04-2025',
nextDue: '30-04-2026',
fields: [
{
ref: 'oss5:Profile.Branches',
section: 'Network',
label: 'Number of Branches',
value: 8,
unit: 'Number',
type: 'numeric'
},
{
ref: 'oss5:Profile.Employees',
section: 'Network',
label: 'Number of Employees',
value: 64,
unit: 'Number',
type: 'numeric'
},
{
ref: 'oss5:Profile.AreaOfOperation',
section: 'Network',
label: 'Area of Operation',
value: 'Single District',
type: 'string'
},
{
ref: 'oss5:Profile.Members',
section: 'Membership',
label: 'Number of Regular Members',
value: 18450,
unit: 'Number',
type: 'numeric'
},
{
ref: 'oss5:Profile.ShareCapital',
section: 'Membership',
label: 'Membership Share Capital (₹ Lakh)',
value: 272.78,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'oss5:Profile.Depositors',
section: 'Customer Base',
label: 'Number of Depositors',
value: 42180,
unit: 'Number',
type: 'numeric'
},
{
ref: 'oss5:Profile.Borrowers',
section: 'Customer Base',
label: 'Number of Borrowers',
value: 3860,
unit: 'Number',
type: 'numeric'
}
]
},
'OSS-6': {
code: 'OSS-6',
name: 'OSS-6',
fullName: 'Structural Liquidity &amp; Interest Rate Sensitivity Statement',
description:
'Fortnightly maturity-bucket gap analysis of rate-sensitive assets and liabilities. Illustrative figures — no source filing was provided for this return.',
cimsCode: 'CIMS_OSS_006',
taxonomy: 'rbi-cims-oss6-2024-04-01',
periodicity: 'Monthly',
lastFiled: '14-04-2026',
nextDue: '28-04-2026',
fields: [
{
ref: 'oss6:Gap.1to14Days',
section: 'Maturity Gap (₹ Lakh)',
label: '1-14 Days',
value: -45.2,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'oss6:Gap.15to28Days',
section: 'Maturity Gap (₹ Lakh)',
label: '15-28 Days',
value: -12.8,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'oss6:Gap.29DaysTo3M',
section: 'Maturity Gap (₹ Lakh)',
label: '29 Days to 3 Months',
value: 88.4,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'oss6:Gap.Over3To6M',
section: 'Maturity Gap (₹ Lakh)',
label: 'Over 3 Months to 6 Months',
value: 62.15,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'oss6:Gap.Over6MTo1Y',
section: 'Maturity Gap (₹ Lakh)',
label: 'Over 6 Months to 1 Year',
value: -28.9,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'oss6:Gap.Over1To3Y',
section: 'Maturity Gap (₹ Lakh)',
label: 'Over 1 Year to 3 Years',
value: 210.6,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'oss6:Gap.Over3To5Y',
section: 'Maturity Gap (₹ Lakh)',
label: 'Over 3 Years to 5 Years',
value: 95.3,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'oss6:Gap.Over5Y',
section: 'Maturity Gap (₹ Lakh)',
label: 'Over 5 Years',
value: -18.75,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'oss6:Liq.CumNegGapPct',
section: 'Liquidity Ratios',
label: 'Cumulative Negative Gap (14-Day) as % of Cumulative Outflows',
value: 4.85,
unit: 'Percent',
type: 'percent'
}
]
},
'OSS-7': {
code: 'OSS-7',
name: 'OSS-7',
fullName: 'Statement of Frauds &amp; Customer Complaints',
description:
'Quarterly statement of frauds reported and customer-complaint disposal. Illustrative figures — no source filing was provided for this return.',
cimsCode: 'CIMS_OSS_007',
taxonomy: 'rbi-cims-oss7-2024-04-01',
periodicity: 'Quarterly',
lastFiled: '15-01-2026',
nextDue: '15-04-2026',
fields: [
{
ref: 'oss7:Fraud.Count',
section: 'Frauds',
label: 'Number of Frauds Reported',
value: 1,
unit: 'Number',
type: 'numeric'
},
{
ref: 'oss7:Fraud.Amount',
section: 'Frauds',
label: 'Amount Involved in Frauds (₹ Lakh)',
value: 2.4,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'oss7:Complaints.Received',
section: 'Customer Complaints',
label: 'Complaints Received (Quarter)',
value: 14,
unit: 'Number',
type: 'numeric'
},
{
ref: 'oss7:Complaints.Resolved',
section: 'Customer Complaints',
label: 'Complaints Resolved',
value: 12,
unit: 'Number',
type: 'numeric'
},
{
ref: 'oss7:Complaints.Pending',
section: 'Customer Complaints',
label: 'Complaints Pending',
value: 2,
unit: 'Number',
type: 'numeric'
},
{
ref: 'oss7:Complaints.PendingOver30',
section: 'Customer Complaints',
label: 'Complaints Pending &gt; 30 Days',
value: 0,
unit: 'Number',
type: 'numeric'
}
]
}
};
@@ -660,7 +947,11 @@ export class CimsXbrlDialogComponent {
'OSS-2',
'PSA',
'RAL',
'RCP-UCB'
'RCP-UCB',
'OSS-4',
'OSS-5',
'OSS-6',
'OSS-7'
];
data = inject<CimsXbrlDialogData | null>(MAT_DIALOG_DATA, { optional: true });
@@ -76,7 +76,7 @@ export class CkycReportsDialogComponent {
activeReport: CkycReportCode;
bank = {
name: 'Bharat Urban Bank Ltd',
name: 'CBSNexora Bank Ltd',
branch: 'Head Office',
asOnDate: '29-01-2026',
fiscalYear: '2025-26',
@@ -84,7 +84,7 @@ export class CkycReportsDialogComponent {
principalOfficer: 'Karthik Kamath',
poDesignation: 'Principal Compliance Officer',
reportRefNo: '2075/1',
userCode: 'BUBL01',
userCode: 'CBSN01',
fyStart: '01-04-2025',
fyEnd: '31-03-2026'
};
@@ -121,7 +121,7 @@ export class CkycReportsDialogComponent {
rekyc: RekycRow[] = [
{
ucode: 'BUBL000201',
ucode: 'CBSN000201',
name: 'Ravi Kumar Sharma',
customerType: 'INDIVIDUAL',
risk: 'LOW',
@@ -132,7 +132,7 @@ export class CkycReportsDialogComponent {
segment: 'BELOW-25-LACS'
},
{
ucode: 'BUBL000418',
ucode: 'CBSN000418',
name: 'Priya Patel',
customerType: 'INDIVIDUAL',
risk: 'MEDIUM',
@@ -143,7 +143,7 @@ export class CkycReportsDialogComponent {
segment: 'ABOVE-25-LACS'
},
{
ucode: 'BUBL000522',
ucode: 'CBSN000522',
name: 'Arjun Deshmukh',
customerType: 'INDIVIDUAL',
risk: 'LOW',
@@ -154,7 +154,7 @@ export class CkycReportsDialogComponent {
segment: 'BELOW-25-LACS'
},
{
ucode: 'BUBL000711',
ucode: 'CBSN000711',
name: 'Vikram Reddy',
customerType: 'INDIVIDUAL',
risk: 'HIGH',
@@ -165,7 +165,7 @@ export class CkycReportsDialogComponent {
segment: 'ABOVE-25-LACS'
},
{
ucode: 'BUBL000814',
ucode: 'CBSN000814',
name: 'Lakshmi Iyer',
customerType: 'INDIVIDUAL',
risk: 'LOW',
@@ -176,7 +176,7 @@ export class CkycReportsDialogComponent {
segment: 'BELOW-25-LACS'
},
{
ucode: 'BUBL001012',
ucode: 'CBSN001012',
name: 'Nirav Shah',
customerType: 'INDIVIDUAL',
risk: 'MEDIUM',
@@ -187,7 +187,7 @@ export class CkycReportsDialogComponent {
segment: 'BELOW-25-LACS'
},
{
ucode: 'BUBL001145',
ucode: 'CBSN001145',
name: 'Kavita Verma',
customerType: 'INDIVIDUAL',
risk: 'HIGH',
@@ -198,7 +198,7 @@ export class CkycReportsDialogComponent {
segment: 'ABOVE-25-LACS'
},
{
ucode: 'BUBL001458',
ucode: 'CBSN001458',
name: 'Sameer Kapoor',
customerType: 'INDIVIDUAL',
risk: 'MEDIUM',
@@ -209,7 +209,7 @@ export class CkycReportsDialogComponent {
segment: 'ABOVE-25-LACS'
},
{
ucode: 'BUBL001721',
ucode: 'CBSN001721',
name: 'Karthik Subramanian',
customerType: 'INDIVIDUAL',
risk: 'LOW',
@@ -220,7 +220,7 @@ export class CkycReportsDialogComponent {
segment: 'BELOW-25-LACS'
},
{
ucode: 'BUBL002012',
ucode: 'CBSN002012',
name: 'Abhishek Banerjee',
customerType: 'INDIVIDUAL',
risk: 'MEDIUM',
@@ -231,7 +231,7 @@ export class CkycReportsDialogComponent {
segment: 'ABOVE-25-LACS'
},
{
ucode: 'BUBL002314',
ucode: 'CBSN002314',
name: 'Gaurav Thakur',
customerType: 'INDIVIDUAL',
risk: 'LOW',
@@ -242,7 +242,7 @@ export class CkycReportsDialogComponent {
segment: 'BELOW-25-LACS'
},
{
ucode: 'BUBL002615',
ucode: 'CBSN002615',
name: 'Manmohan Singh Gill',
customerType: 'INDIVIDUAL',
risk: 'HIGH',
@@ -253,7 +253,7 @@ export class CkycReportsDialogComponent {
segment: 'BELOW-25-LACS'
},
{
ucode: 'BUBL002918',
ucode: 'CBSN002918',
name: 'Sunita Devi',
customerType: 'INDIVIDUAL',
risk: 'LOW',
@@ -264,7 +264,7 @@ export class CkycReportsDialogComponent {
segment: 'BELOW-25-LACS'
},
{
ucode: 'BUBL003221',
ucode: 'CBSN003221',
name: 'Deepak Tiwari',
customerType: 'INDIVIDUAL',
risk: 'MEDIUM',
@@ -275,7 +275,7 @@ export class CkycReportsDialogComponent {
segment: 'ABOVE-25-LACS'
},
{
ucode: 'BUBL003524',
ucode: 'CBSN003524',
name: 'Harpreet Kaur',
customerType: 'INDIVIDUAL',
risk: 'MEDIUM',
@@ -286,7 +286,7 @@ export class CkycReportsDialogComponent {
segment: 'ABOVE-25-LACS'
},
{
ucode: 'BUBL003812',
ucode: 'CBSN003812',
name: 'Saraswati Mahila Co-op Society',
customerType: 'SOCIETY',
risk: 'MEDIUM',
@@ -297,7 +297,7 @@ export class CkycReportsDialogComponent {
segment: 'ABOVE-25-LACS'
},
{
ucode: 'BUBL004115',
ucode: 'CBSN004115',
name: 'Aditya Enterprises',
customerType: 'FIRM',
risk: 'HIGH',
@@ -308,7 +308,7 @@ export class CkycReportsDialogComponent {
segment: 'ABOVE-25-LACS'
},
{
ucode: 'BUBL004318',
ucode: 'CBSN004318',
name: 'Vasudha Trading HUF',
customerType: 'HUF',
risk: 'MEDIUM',
@@ -332,7 +332,7 @@ export class CkycReportsDialogComponent {
branchCode: '01',
branch: 'Mumbai Main',
acType: 'SB',
clientId: 'BUBL000201',
clientId: 'CBSN000201',
customerName: 'Ravi Kumar Sharma',
oldRisk: 'LOW',
newRisk: 'LOW',
@@ -345,7 +345,7 @@ export class CkycReportsDialogComponent {
branchCode: '02',
branch: 'Pune FC Road',
acType: 'CA',
clientId: 'BUBL000418',
clientId: 'CBSN000418',
customerName: 'Priya Patel',
oldRisk: 'LOW',
newRisk: 'MEDIUM',
@@ -358,7 +358,7 @@ export class CkycReportsDialogComponent {
branchCode: '03',
branch: 'Nagpur Dharampeth',
acType: 'TL',
clientId: 'BUBL000522',
clientId: 'CBSN000522',
customerName: 'Arjun Deshmukh',
oldRisk: 'MEDIUM',
newRisk: 'LOW',
@@ -371,7 +371,7 @@ export class CkycReportsDialogComponent {
branchCode: '04',
branch: 'Bengaluru Jayanagar',
acType: 'OD',
clientId: 'BUBL000711',
clientId: 'CBSN000711',
customerName: 'Vikram Reddy',
oldRisk: 'MEDIUM',
newRisk: 'HIGH',
@@ -384,7 +384,7 @@ export class CkycReportsDialogComponent {
branchCode: '05',
branch: 'Bengaluru Indiranagar',
acType: 'SB',
clientId: 'BUBL000814',
clientId: 'CBSN000814',
customerName: 'Lakshmi Iyer',
oldRisk: 'LOW',
newRisk: 'LOW',
@@ -397,7 +397,7 @@ export class CkycReportsDialogComponent {
branchCode: '06',
branch: 'Ahmedabad Satellite',
acType: 'CC',
clientId: 'BUBL001012',
clientId: 'CBSN001012',
customerName: 'Nirav Shah',
oldRisk: 'LOW',
newRisk: 'MEDIUM',
@@ -410,7 +410,7 @@ export class CkycReportsDialogComponent {
branchCode: '07',
branch: 'Lucknow Gomti Nagar',
acType: 'CA',
clientId: 'BUBL001145',
clientId: 'CBSN001145',
customerName: 'Kavita Verma',
oldRisk: 'MEDIUM',
newRisk: 'HIGH',
@@ -423,7 +423,7 @@ export class CkycReportsDialogComponent {
branchCode: '08',
branch: 'Delhi Greater Kailash',
acType: 'OD',
clientId: 'BUBL001458',
clientId: 'CBSN001458',
customerName: 'Sameer Kapoor',
oldRisk: 'MEDIUM',
newRisk: 'MEDIUM',
@@ -436,7 +436,7 @@ export class CkycReportsDialogComponent {
branchCode: '09',
branch: 'Chennai Valasaravakkam',
acType: 'SB',
clientId: 'BUBL001721',
clientId: 'CBSN001721',
customerName: 'Karthik Subramanian',
oldRisk: 'LOW',
newRisk: 'LOW',
@@ -449,7 +449,7 @@ export class CkycReportsDialogComponent {
branchCode: '10',
branch: 'Kolkata Salt Lake',
acType: 'TL',
clientId: 'BUBL002012',
clientId: 'CBSN002012',
customerName: 'Abhishek Banerjee',
oldRisk: 'LOW',
newRisk: 'MEDIUM',
@@ -462,7 +462,7 @@ export class CkycReportsDialogComponent {
branchCode: '11',
branch: 'Shimla Sector 4',
acType: 'SB',
clientId: 'BUBL002314',
clientId: 'CBSN002314',
customerName: 'Gaurav Thakur',
oldRisk: 'LOW',
newRisk: 'LOW',
@@ -475,7 +475,7 @@ export class CkycReportsDialogComponent {
branchCode: '12',
branch: 'Amritsar Model Town',
acType: 'CC',
clientId: 'BUBL002615',
clientId: 'CBSN002615',
customerName: 'Manmohan Singh Gill',
oldRisk: 'HIGH',
newRisk: 'HIGH',
@@ -488,7 +488,7 @@ export class CkycReportsDialogComponent {
branchCode: '13',
branch: 'Bhopal Arera Colony',
acType: 'SB',
clientId: 'BUBL002918',
clientId: 'CBSN002918',
customerName: 'Sunita Devi',
oldRisk: 'LOW',
newRisk: 'LOW',
@@ -501,7 +501,7 @@ export class CkycReportsDialogComponent {
branchCode: '14',
branch: 'Indore Vijay Nagar',
acType: 'OD',
clientId: 'BUBL003221',
clientId: 'CBSN003221',
customerName: 'Deepak Tiwari',
oldRisk: 'MEDIUM',
newRisk: 'HIGH',
@@ -514,7 +514,7 @@ export class CkycReportsDialogComponent {
branchCode: '15',
branch: 'Ludhiana Sarabha Nagar',
acType: 'TL',
clientId: 'BUBL003524',
clientId: 'CBSN003524',
customerName: 'Harpreet Kaur',
oldRisk: 'MEDIUM',
newRisk: 'MEDIUM',
@@ -1,285 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="ctr-str-dlg">
<!-- Dialog Header -->
<div class="dlg-header">
<div class="dlg-title-area">
<fa-icon icon="shield-alt" class="header-icon"></fa-icon>
<div>
<h2 class="title">CTR & STR Compliance Control Center</h2>
<p class="subtitle">Anti-Money Laundering (AML) & CFT Regulatory Reporting Terminal</p>
</div>
</div>
<button mat-icon-button [mat-dialog-close]="true" class="close-btn">
<mat-icon>close</mat-icon>
</button>
</div>
<!-- Tab Navigators -->
<div class="tab-navs">
<button class="tab-btn" [class.active]="activeTab === 'dashboard'" (click)="activeTab = 'dashboard'">
<fa-icon icon="chart-bar" class="tab-icon"></fa-icon> Compliance Dashboard
</button>
<button class="tab-btn" [class.active]="activeTab === 'guidelines'" (click)="activeTab = 'guidelines'">
<fa-icon icon="university" class="tab-icon"></fa-icon> RBI Regulatory Guidelines
</button>
</div>
<!-- Dialog Body (Tabs) -->
<div class="dlg-body">
<!-- TAB 1: COMPLIANCE DASHBOARD -->
@if (activeTab === 'dashboard') {
<div class="tab-content dashboard-tab">
<!-- SECTION 1: CTR MANAGMENT -->
<div class="dashboard-section card-glass">
<div class="section-hdr">
<div>
<h3>Cash Transaction Reports (CTR - Monthly Cash Registry)</h3>
<p class="section-sub">
Scans and compiles cash transactions exceeding INR 10 Lakhs limit (Single or monthly cumulative cash
deposits/withdrawals).
</p>
</div>
<button class="action-btn primary" [disabled]="isGeneratingCtr" (click)="generateCtrReport()">
@if (isGeneratingCtr) {
<span class="spinner"></span> Generating Report...
} @else {
<fa-icon icon="sync-alt"></fa-icon> Generate Monthly CTR
}
</button>
</div>
<div class="table-actions">
<input
type="text"
placeholder="Search CTR files by month or name..."
[(ngModel)]="ctrFilter"
class="search-input"
/>
</div>
<div class="table-container">
<table class="compliance-table">
<thead>
<tr>
<th>Report Payload Name</th>
<th>Reporting Period</th>
<th>Record Count</th>
<th>Generated By</th>
<th>Generated Date</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (report of filteredCtrReports; track report.id) {
<tr>
<td class="bold-text"><fa-icon icon="file-code" class="xml-icon"></fa-icon> {{ report.name }}</td>
<td>{{ report.month }}</td>
<td class="center-text">
<span class="cnt-badge">{{ report.recordCount }} txns</span>
</td>
<td>{{ report.generatedBy }}</td>
<td>{{ report.generatedAt | date: 'medium' }}</td>
<td><span class="status-badge ready">Ready</span></td>
<td>
<button
class="icon-action-btn"
matTooltip="Download FIU-IND XML Payload"
(click)="downloadCtrFile(report.name)"
>
<fa-icon icon="download"></fa-icon> Download XML
</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="empty-state">No CTR reports found matching your search.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<!-- SECTION 2: STR ALERTS PIPELINE -->
<div class="dashboard-section card-glass mt-20">
<div class="section-hdr">
<div>
<h3>Suspicious Transaction Activity Pipeline (STR Management)</h3>
<p class="section-sub">
Behavioral & structural AML alerts generated by KYC profile rules engine. Investigator actions update
state in real-time.
</p>
</div>
<button class="action-btn secondary" (click)="downloadStrReportTemplate()">
<fa-icon icon="file-excel"></fa-icon> Export Active Alerts (CSV)
</button>
</div>
<div class="table-actions">
<input
type="text"
placeholder="Search alerts by Client, CIF, or alert type..."
[(ngModel)]="strFilter"
class="search-input"
/>
</div>
<div class="alerts-grid">
@for (alert of filteredStrAlerts; track alert.id) {
<div class="alert-card" [class]="alert.status.toLowerCase().replace(' ', '-')">
<div class="card-hdr">
<div class="client-info">
<h4>{{ alert.clientName }}</h4>
<span class="cif-number">{{ alert.clientCif }}</span>
</div>
<span class="status-badge" [class]="alert.status.toLowerCase().replace(' ', '-')">{{
alert.status
}}</span>
</div>
<div class="card-body">
<div class="amount-line">
<span class="label">Amount Highlighted:</span>
<span class="amount">₹{{ alert.amount | number: '1.2-2' }}</span>
</div>
<div class="type-line">
<span class="label">Alert Flag:</span>
<span class="type-value">{{ alert.alertType }}</span>
</div>
<p class="desc">{{ alert.description }}</p>
<span class="date-stamp"
><fa-icon icon="clock"></fa-icon> Flagged Date: {{ alert.date | date: 'mediumDate' }}</span
>
</div>
<div class="card-footer">
@if (alert.status === 'Open') {
<button class="card-btn investigate" (click)="changeAlertStatus(alert.id, 'Investigating')">
<fa-icon icon="search"></fa-icon> Investigate
</button>
}
@if (alert.status === 'Open' || alert.status === 'Investigating') {
<button class="card-btn file-str" (click)="changeAlertStatus(alert.id, 'Reported STR')">
<fa-icon icon="flag"></fa-icon> File STR
</button>
<button class="card-btn close-alert" (click)="changeAlertStatus(alert.id, 'Closed')">
<fa-icon icon="ban"></fa-icon> Dismiss
</button>
}
@if (alert.status === 'Closed' || alert.status === 'Reported STR') {
<button class="card-btn reset-alert" (click)="changeAlertStatus(alert.id, 'Open')">
<fa-icon icon="redo-alt"></fa-icon> Reopen Alert
</button>
}
</div>
</div>
} @empty {
<div class="empty-alerts">No Suspicious Transaction alerts matching your criteria.</div>
}
</div>
</div>
</div>
}
<!-- TAB 2: RBI REGULATORY GUIDELINES -->
@if (activeTab === 'guidelines') {
<div class="tab-content scroll-y">
<div class="info-layout">
<!-- CTR Rules Card -->
<div class="info-card card-glass">
<div class="card-title-badge ctr-badge-color">
<fa-icon icon="coins" class="badge-icon"></fa-icon> CTR - Cash Transaction Report
</div>
<div class="rules-body">
<p class="lead-desc">
As mandated by Section 12 of the **Prevention of Money Laundering Act (PMLA), 2002** and RBI KYC
guidelines, banks must record and report all high-value cash transactions.
</p>
<div class="rule-point">
<h5>1. Transaction Threshold</h5>
<p>
All cash transactions (either deposits or withdrawals) of **value more than INR 10 Lakhs** (or its
equivalent in foreign currency) in a single transaction.
</p>
</div>
<div class="rule-point">
<h5>2. Monthly Aggregation Rule (Structuring check)</h5>
<p>
A series of cash transactions in a calendar month that are **integrally connected** and accumulate to
a total value exceeding **INR 10 Lakhs** must be compiled and reported in the monthly CTR.
</p>
</div>
<div class="rule-point">
<h5>3. Reporting Timeline</h5>
<p>
CTRs must be generated and uploaded to the FIU-IND gateway **by the 15th day of the succeeding month**
(e.g., June transactions must be uploaded by July 15th).
</p>
</div>
<div class="rule-point text-warning-border">
<strong>Important Note:</strong> Transactions where the net cash balance does not change but high volume
is rotated (e.g., cash withdrawals immediately deposited back) must be reported under CTR as separate
debit and credit records.
</div>
</div>
</div>
<!-- STR Rules Card -->
<div class="info-card card-glass">
<div class="card-title-badge str-badge-color">
<fa-icon icon="flag" class="badge-icon"></fa-icon> STR - Suspicious Transaction Report
</div>
<div class="rules-body">
<p class="lead-desc">
Every reporting entity must maintain vigilance over account transactions. If there are reasonable
grounds to suspect transactions involve proceeds of crime, an STR must be generated.
</p>
<div class="rule-point">
<h5>1. What Constitutes Suspicion?</h5>
<p>
Transactions that are complex, show unusual patterns, mismatch the customer’s business/risk profile,
have no apparent economic rationale, or involve suspected proceeds of crime or terror funding.
</p>
</div>
<div class="rule-point">
<h5>2. No Minimum Threshold Limit</h5>
<p>
**There is no minimum monetary limit** for STR. An alert should be generated for any transaction,
regardless of size, if suspicious behavior is flagged.
</p>
</div>
<div class="rule-point">
<h5>3. Reporting Timeline</h5>
<p>
The STR must be filed to FIU-IND **within 7 days** of the Principal Compliance Officer confirming the
suspicion.
</p>
</div>
<div class="rule-point text-danger-border">
<strong>Strict Tipping-off Restriction:</strong> It is illegal to disclose or "tip-off" to the client
that their account is being monitored, investigated, or reported under an STR. Complete confidentiality
must be maintained.
</div>
</div>
</div>
</div>
</div>
}
</div>
</div>
@@ -1,750 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.ctr-str-dlg {
display: flex;
flex-direction: column;
height: 90vh;
max-height: 820px;
background: #f8fafc;
color: #1e293b;
font-family: Inter, sans-serif;
overflow: hidden;
}
// Header
.dlg-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
.dlg-title-area {
display: flex;
align-items: center;
gap: 14px;
.header-icon {
font-size: 26px;
color: #4f46e5;
}
.title {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #0f172a;
}
.subtitle {
margin: 2px 0 0;
font-size: 12px;
color: #64748b;
}
}
.close-btn {
color: #64748b;
&:hover {
color: #0f172a;
}
}
}
// Navigation Tabs
.tab-navs {
display: flex;
gap: 6px;
background: #fff;
padding: 8px 24px;
border-bottom: 1px solid #e2e8f0;
.tab-btn {
display: flex;
align-items: center;
gap: 8px;
background: transparent;
border: none;
padding: 10px 16px;
font-size: 13px;
font-weight: 600;
color: #64748b;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s ease;
.tab-icon {
font-size: 14px;
}
&:hover {
background: #f1f5f9;
color: #0f172a;
}
&.active {
background: #e0e7ff;
color: #4f46e5;
}
}
}
// Scroll layout
.scroll-y {
overflow-y: auto;
flex: 1;
}
// Dialog Body
.dlg-body {
flex: 1;
overflow: hidden;
padding: 24px;
background: #f1f5f9;
display: flex;
flex-direction: column;
}
.tab-content {
height: 100%;
display: flex;
flex-direction: column;
&.scroll-y {
overflow-y: auto;
padding-right: 6px;
/* Custom scrollbar */
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
&:hover {
background: #94a3b8;
}
}
}
}
// Dashboard Tab specific layout
.dashboard-tab {
overflow-y: auto;
padding-right: 6px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
}
// Card Style Container
.card-glass {
background: #fff;
border-radius: 12px;
box-shadow:
0 4px 6px -1px rgb(0 0 0 / 5%),
0 2px 4px -1px rgb(0 0 0 / 2%);
border: 1px solid #e2e8f0;
padding: 20px;
}
.dashboard-section {
display: flex;
flex-direction: column;
.section-hdr {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h3 {
margin: 0;
font-size: 15px;
font-weight: 700;
color: #0f172a;
}
.section-sub {
margin: 4px 0 0;
font-size: 12px;
color: #64748b;
}
}
}
.mt-20 {
margin-top: 20px;
}
.mt-25 {
margin-top: 25px;
}
// Search input
.table-actions {
margin-bottom: 14px;
.search-input {
width: 100%;
max-width: 380px;
padding: 8px 12px;
font-size: 13px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
color: #1e293b;
outline: none;
&:focus {
border-color: #4f46e5;
box-shadow: 0 0 0 2px rgb(79 70 229 / 10%);
}
}
}
// Button styles
.action-btn {
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
border-radius: 6px;
padding: 10px 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
&.primary {
background: #4f46e5;
color: #fff;
&:hover:not([disabled]) {
background: #4338ca;
}
&:disabled {
background: #94a3b8;
cursor: not-allowed;
}
}
&.secondary {
background: #0ea5e9;
color: #fff;
&:hover {
background: #0284c7;
}
}
}
// Spinner animation
.spinner {
width: 12px;
height: 12px;
border: 2px solid rgb(255 255 255 / 30%);
border-radius: 50%;
border-top-color: #fff;
animation: spin 0.8s linear infinite;
display: inline-block;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
// Tables styling
.table-container {
overflow-x: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.compliance-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
text-align: left;
th {
background: #f8fafc;
color: #475569;
font-weight: 600;
padding: 12px 16px;
border-bottom: 1px solid #e2e8f0;
}
td {
padding: 12px 16px;
border-bottom: 1px solid #f1f5f9;
color: #334155;
&.bold-text {
font-weight: 600;
color: #0f172a;
}
&.center-text {
text-align: center;
}
}
tr:last-child td {
border-bottom: none;
}
.cnt-badge {
background: #e2e8f0;
color: #475569;
padding: 3px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
}
.xml-icon {
color: #ea580c;
margin-right: 4px;
}
.empty-state {
text-align: center;
color: #64748b;
padding: 24px;
font-style: italic;
}
}
// Icon Action Button
.icon-action-btn {
background: #f1f5f9;
border: 1px solid #cbd5e1;
color: #475569;
border-radius: 4px;
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #e2e8f0;
color: #0f172a;
}
}
// Alert Cards Grid
.alerts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.alert-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: column;
transition: box-shadow 0.2s ease;
&:hover {
box-shadow: 0 4px 12px rgb(0 0 0 / 5%);
}
// Left boundary color codes based on status
&.open {
border-left: 4px solid #dc2626;
}
&.investigating {
border-left: 4px solid #ea580c;
}
&.reported-str {
border-left: 4px solid #16a34a;
}
&.closed {
border-left: 4px solid #64748b;
}
.card-hdr {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
h4 {
margin: 0;
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
.cif-number {
font-size: 11px;
color: #64748b;
}
}
.card-body {
flex: 1;
font-size: 12px;
color: #475569;
.amount-line,
.type-line {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
.label {
color: #64748b;
}
.amount {
font-weight: 700;
color: #0f172a;
}
.type-value {
font-weight: 600;
color: #4f46e5;
}
}
.desc {
margin: 8px 0;
line-height: 1.4;
background: #f8fafc;
padding: 8px;
border-radius: 4px;
border: 1px dashed #e2e8f0;
font-style: italic;
}
.date-stamp {
font-size: 11px;
color: #94a3b8;
display: block;
margin-top: 8px;
}
}
.card-footer {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 14px;
border-top: 1px solid #f1f5f9;
padding-top: 12px;
.card-btn {
flex: 1;
min-width: 75px;
padding: 6px 8px;
font-size: 11px;
font-weight: 600;
border: none;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
transition: all 0.2s ease;
&.investigate {
background: #fef3c7;
color: #d97706;
&:hover {
background: #fde68a;
}
}
&.file-str {
background: #dcfce7;
color: #16a34a;
&:hover {
background: #bbf7d0;
}
}
&.close-alert {
background: #fee2e2;
color: #dc2626;
&:hover {
background: #fecaca;
}
}
&.reset-alert {
background: #f1f5f9;
color: #475569;
border: 1px solid #cbd5e1;
&:hover {
background: #e2e8f0;
}
}
}
}
}
// Badges
.status-badge {
padding: 3px 8px;
border-radius: 12px;
font-size: 10px;
font-weight: 600;
&.ready {
background: #dcfce7;
color: #16a34a;
}
&.open {
background: #fee2e2;
color: #dc2626;
}
&.investigating {
background: #fef3c7;
color: #d97706;
}
&.reported-str {
background: #dbeafe;
color: #2563eb;
}
&.closed {
background: #f1f5f9;
color: #475569;
}
}
.empty-alerts {
grid-column: 1 / -1;
text-align: center;
padding: 36px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
color: #64748b;
font-style: italic;
font-size: 13px;
}
// Tab 2: Guidelines layout
.info-layout {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
@media (width >= 900px) {
grid-template-columns: 1fr 1fr;
}
}
.info-card {
display: flex;
flex-direction: column;
.card-title-badge {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border-radius: 20px;
font-size: 12px;
font-weight: 700;
margin-bottom: 16px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #fff;
}
.ctr-badge-color {
background: #ea580c;
}
.str-badge-color {
background: #be185d;
}
.rules-body {
font-size: 13px;
line-height: 1.5;
color: #334155;
.lead-desc {
font-size: 14px;
color: #0f172a;
margin-bottom: 20px;
}
.rule-point {
margin-bottom: 18px;
h5 {
margin: 0 0 6px;
font-size: 13px;
font-weight: 700;
color: #0f172a;
}
p {
margin: 0;
color: #475569;
}
}
.text-warning-border {
border-left: 3px solid #eab308;
background: #fef9c3;
padding: 10px 14px;
border-radius: 0 6px 6px 0;
color: #854d0e;
}
.text-danger-border {
border-left: 3px solid #ef4444;
background: #fee2e2;
padding: 10px 14px;
border-radius: 0 6px 6px 0;
color: #991b1b;
}
}
}
// Tab 3: Architecture & Schema
.arch-container {
display: flex;
flex-direction: column;
h3 {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
.desc {
margin: 4px 0 20px;
font-size: 12px;
color: #64748b;
}
}
.arch-section {
h4 {
margin: 0 0 4px;
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
.section-sub {
margin: 0 0 10px;
font-size: 12px;
color: #64748b;
}
}
.code-snippet {
background: #0f172a;
color: #e2e8f0;
padding: 14px;
border-radius: 8px;
font-family: 'Courier New', Courier, monospace;
font-size: 12px;
overflow-x: auto;
line-height: 1.4;
margin: 0;
&.plain {
background: #1e293b;
color: #94a3b8;
}
}
// API Routes
.route-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
margin-bottom: 8px;
font-size: 12px;
.method {
padding: 3px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
color: #fff;
min-width: 45px;
text-align: center;
&.get {
background: #16a34a;
}
&.put {
background: #ea580c;
}
&.post {
background: #2563eb;
}
}
.path {
font-family: 'Courier New', Courier, monospace;
font-weight: 700;
color: #0f172a;
}
.route-desc {
color: #475569;
flex: 1;
}
}
@@ -1,279 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatTabsModule } from '@angular/material/tabs';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatIconModule } from '@angular/material/icon';
import { FaIconComponent, FaIconLibrary } from '@fortawesome/angular-fontawesome';
import {
faShieldAlt,
faChartBar,
faUniversity,
faCode,
faSyncAlt,
faFileExcel,
faFileCode,
faDownload,
faClock,
faSearch,
faFlag,
faBan,
faRedoAlt,
faCoins
} from '@fortawesome/free-solid-svg-icons';
interface CtrReport {
id: number;
name: string;
month: string;
recordCount: number;
generatedBy: string;
generatedAt: Date;
status: 'Ready' | 'Generating';
}
interface StrAlert {
id: number;
clientName: string;
clientCif: string;
alertType: string;
amount: number;
status: 'Open' | 'Investigating' | 'Reported STR' | 'Closed';
description: string;
date: Date;
}
@Component({
selector: 'mifosx-ctr-str-dialog',
standalone: true,
templateUrl: './ctr-str-dialog.component.html',
styleUrls: ['./ctr-str-dialog.component.scss'],
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatButtonModule,
MatTabsModule,
MatTooltipModule,
MatIconModule,
FaIconComponent
]
})
export class CtrStrDialogComponent {
dialogRef = inject(MatDialogRef<CtrStrDialogComponent>);
constructor() {
const library = inject(FaIconLibrary);
library.addIcons(
faShieldAlt,
faChartBar,
faUniversity,
faCode,
faSyncAlt,
faFileExcel,
faFileCode,
faDownload,
faClock,
faSearch,
faFlag,
faBan,
faRedoAlt,
faCoins
);
}
activeTab: 'dashboard' | 'guidelines' = 'dashboard';
// Mock State
isGeneratingCtr = false;
ctrFilter = '';
strFilter = '';
ctrReports: CtrReport[] = [
{
id: 1,
name: 'CTR_REPORT_JUNE_2026.xml',
month: 'June 2026',
recordCount: 14,
generatedBy: 'Compliance_Officer_1',
generatedAt: new Date(2026, 6, 5, 10, 30),
status: 'Ready'
},
{
id: 2,
name: 'CTR_REPORT_MAY_2026.xml',
month: 'May 2026',
recordCount: 22,
generatedBy: 'Compliance_Officer_1',
generatedAt: new Date(2026, 5, 4, 14, 15),
status: 'Ready'
},
{
id: 3,
name: 'CTR_REPORT_APRIL_2026.xml',
month: 'April 2026',
recordCount: 9,
generatedBy: 'Compliance_Officer_2',
generatedAt: new Date(2026, 4, 3, 11, 45),
status: 'Ready'
}
];
strAlerts: StrAlert[] = [
{
id: 101,
clientName: 'Aarav Sharma',
clientCif: 'CIF982348',
alertType: 'Profile Mismatch',
amount: 450000,
status: 'Open',
description:
'Student account received a large inward transfer (INR 4.5L) mismatching declared zero/student income category.',
date: new Date(2026, 6, 26)
},
{
id: 102,
clientName: 'Karan Malhotra',
clientCif: 'CIF229482',
alertType: 'Structuring (Smurfing)',
amount: 980000,
status: 'Investigating',
description:
'Multiple cash deposits of INR 2.45L, 2.45L, 2.45L, 2.45L within 10 days, totaling INR 9.8L cash (deliberately staying below the 10L single cash transaction CTR threshold).',
date: new Date(2026, 6, 24)
},
{
id: 103,
clientName: 'Nexora Real Estate Ltd',
clientCif: 'CIF500481',
alertType: 'Turnover Outlier',
amount: 15000000,
status: 'Open',
description:
'Entity account registered an outward wire of INR 1.5Cr when maximum monthly turnover limit is set to INR 50L.',
date: new Date(2026, 6, 20)
},
{
id: 104,
clientName: 'Priyanka Patel',
clientCif: 'CIF774920',
alertType: 'Dormant Account Reactivation',
amount: 600000,
status: 'Reported STR',
description:
'Account inactive for 18 months suddenly received a cash deposit of INR 6L, followed by immediate online transfers.',
date: new Date(2026, 6, 15)
}
];
get filteredCtrReports() {
return this.ctrReports.filter(
(r) =>
r.name.toLowerCase().includes(this.ctrFilter.toLowerCase()) ||
r.month.toLowerCase().includes(this.ctrFilter.toLowerCase())
);
}
get filteredStrAlerts() {
return this.strAlerts.filter(
(a) =>
a.clientName.toLowerCase().includes(this.strFilter.toLowerCase()) ||
a.clientCif.toLowerCase().includes(this.strFilter.toLowerCase()) ||
a.alertType.toLowerCase().includes(this.strFilter.toLowerCase())
);
}
// Actions
generateCtrReport() {
if (this.isGeneratingCtr) {
return;
}
this.isGeneratingCtr = true;
// Simulate API call to Spring Boot batch job
setTimeout(() => {
const date = new Date();
const newReport: CtrReport = {
id: this.ctrReports.length + 1,
name: `CTR_REPORT_JULY_2026_DRAFT.xml`,
month: 'July 2026 (Draft)',
recordCount: 8,
generatedBy: 'Current_Admin',
generatedAt: date,
status: 'Ready'
};
this.ctrReports.unshift(newReport);
this.isGeneratingCtr = false;
}, 2000);
}
downloadCtrFile(reportName: string) {
const dummyXml = `<?xml version="1.0" encoding="UTF-8"?>
<FIU-IND-CTR-Report xmlns="http://fiuindia.gov.in/ctr/v1">
<Header>
<ReportType>CTR</ReportType>
<ReportingEntityName>CBS Nexora Bank</ReportingEntityName>
<ReportingPeriod>2026-06</ReportingPeriod>
</Header>
<ReportData>
<Record>
<TransactionID>TXN88924824</TransactionID>
<Date>2026-06-15</Date>
<Mode>CASH</Mode>
<Amount>1250000.00</Amount>
<AccountType>SAVINGS</AccountType>
<CustomerName>Aarav Sharma</CustomerName>
<CifNumber>CIF982348</CifNumber>
</Record>
</ReportData>
</FIU-IND-CTR-Report>`;
const blob = new Blob([dummyXml], { type: 'application/xml' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = reportName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}
changeAlertStatus(alertId: number, newStatus: 'Open' | 'Investigating' | 'Reported STR' | 'Closed') {
const alert = this.strAlerts.find((a) => a.id === alertId);
if (alert) {
alert.status = newStatus;
}
}
downloadStrReportTemplate() {
const dummyCsv =
`AlertID,ClientName,CIF,AlertType,Amount,Status,Description\n` +
this.strAlerts
.map(
(a) =>
`"${a.id}","${a.clientName}","${a.clientCif}","${a.alertType}","${a.amount}","${a.status}","${a.description.replace(/"/g, '""')}"`
)
.join('\n');
const blob = new Blob([dummyCsv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `STR_AML_ALERTS_EXPORT.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}
}
@@ -165,12 +165,12 @@
<div class="da-compare">
<div class="da-item">
<div class="da-label">Total Deposits</div>
<div class="da-value deposit">&#8377; 3,264.42 Cr</div>
<div class="da-bar"><div class="da-fill deposit-fill" style="width: 78%"></div></div>
<div class="da-value deposit">&#8377; 11,540.55 Cr</div>
<div class="da-bar"><div class="da-fill deposit-fill" style="width: 71%"></div></div>
</div>
<div class="da-item">
<div class="da-label">Total Advances</div>
<div class="da-value advance">&#8377; 4,162.65 Cr</div>
<div class="da-value advance">&#8377; 16,277.11 Cr</div>
<div class="da-bar"><div class="da-fill advance-fill" style="width: 100%"></div></div>
</div>
</div>
@@ -0,0 +1,169 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="fdrd-dlg">
<!-- HEADER -->
<div class="fdrd-head">
<div class="fdrd-head-left">
<div class="fdrd-head-ic"><fa-icon icon="piggy-bank"></fa-icon></div>
<div>
<h2 class="fdrd-title">FD / RD Entry</h2>
<p class="fdrd-sub">Open a new Fixed Deposit or Recurring Deposit account.</p>
</div>
</div>
<div class="fdrd-head-actions">
<span class="fdrd-meta"> <fa-icon icon="university"></fa-icon>&nbsp;{{ bank.name }} </span>
<span class="fdrd-meta"> <fa-icon icon="map-marker-alt"></fa-icon>&nbsp;{{ bank.branch }} </span>
<button class="fdrd-close" (click)="close()" matTooltip="Close" aria-label="Close">&#10005;</button>
</div>
</div>
<div class="fdrd-body">
<!-- Success banner -->
<div class="fdrd-success" *ngIf="saved">
<fa-icon icon="check-circle"></fa-icon>
<div>
<strong>{{ lastAccountNo }}</strong> created for <strong>{{ lastClientName }}</strong>
<div class="fdrd-success-sub">You can enter another deposit below, or close this dialog.</div>
</div>
</div>
<!-- FORM -->
<div class="fdrd-section">
<!-- Row 1: Deposit Info | Client & Amount -->
<div class="ws-group-row">
<div class="ws-group">
<span class="ws-caption">Deposit Info</span>
<div class="ws-group__fields">
<div class="ws-f" style="width: 200px">
<label class="ws-f__lbl">Deposit Type</label>
<select class="ws-f__ctl" [(ngModel)]="depositType" (ngModelChange)="onDepositTypeChange()">
<option *ngFor="let dt of depositTypes" [value]="dt.code">{{ dt.label }}</option>
</select>
</div>
<div class="ws-f" style="width: 150px">
<label class="ws-f__lbl">Pay Mode</label>
<select class="ws-f__ctl" [(ngModel)]="payMode">
<option *ngFor="let m of payModes" [value]="m">{{ m }}</option>
</select>
</div>
</div>
</div>
<div class="ws-vdivider"></div>
<div class="ws-group ws-group--flex">
<span class="ws-caption">Client & Amount</span>
<div class="ws-group__fields">
<div class="ws-f" style="flex: 1.5; min-width: 180px">
<label class="ws-f__lbl">Client</label>
<select class="ws-f__ctl" [(ngModel)]="clientId">
<option value="" disabled>-- Select Client --</option>
<option *ngFor="let c of clients" [value]="c.id">{{ c.name }} ({{ c.id }})</option>
</select>
</div>
<div class="ws-f" style="flex: 1; min-width: 120px">
<label class="ws-f__lbl">{{ isRecurring ? 'Monthly Instalment (₹)' : 'Deposit Amount (₹)' }}</label>
<input
class="ws-f__ctl ws-f__ctl--mono"
type="number"
min="0"
[(ngModel)]="principal"
placeholder="e.g. 100000"
/>
</div>
</div>
</div>
</div>
<!-- Row 2: Term & Rate | Instructions -->
<div class="ws-group-row ws-group-row--mt">
<div class="ws-group">
<span class="ws-caption">Interest & Tenure</span>
<div class="ws-group__fields">
<div class="ws-f" style="width: 110px">
<label class="ws-f__lbl">Int. Rate (% p.a.)</label>
<input class="ws-f__ctl" type="number" min="0" step="0.05" [(ngModel)]="interestRate" />
</div>
<div class="ws-f" style="width: 240px">
<label class="ws-f__lbl">Tenure</label>
<div class="fdrd-tenure-row">
<input class="ws-f__ctl" type="number" min="0" [(ngModel)]="tenureYears" />
<span>Yrs</span>
<input class="ws-f__ctl" type="number" min="0" max="11" [(ngModel)]="tenureMonths" />
<span>Mo</span>
<input class="ws-f__ctl" type="number" min="0" max="30" [(ngModel)]="tenureDays" />
<span>Days</span>
</div>
</div>
</div>
</div>
<div class="ws-vdivider"></div>
<div class="ws-group ws-group--flex">
<span class="ws-caption">Nominee & Maturity Details</span>
<div class="ws-group__fields">
<div class="ws-f" style="flex: 1; min-width: 120px">
<label class="ws-f__lbl">Interest Payout</label>
<select class="ws-f__ctl" [(ngModel)]="interestPayout">
<option *ngFor="let o of interestPayoutOptions" [value]="o">{{ o }}</option>
</select>
</div>
<div class="ws-f" style="flex: 1; min-width: 130px">
<label class="ws-f__lbl">Nominee Name (optional)</label>
<input class="ws-f__ctl" type="text" [(ngModel)]="nomineeName" placeholder="Spouse / Child name" />
</div>
<div class="ws-f" style="flex: 1.2; min-width: 160px">
<label class="ws-f__lbl">Maturity Instruction</label>
<select class="ws-f__ctl" [(ngModel)]="maturityInstruction">
<option *ngFor="let m of maturityInstructions" [value]="m">{{ m }}</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- SUMMARY -->
<div class="fdrd-summary">
<div class="fdrd-stat">
<div class="fdrd-stat-l">Principal</div>
<div class="fdrd-stat-v">&#8377; {{ principal || 0 | number: '1.0-2' }}</div>
</div>
<div class="fdrd-stat">
<div class="fdrd-stat-l">Tenure</div>
<div class="fdrd-stat-v">{{ tenureYears }}y {{ tenureMonths }}m {{ tenureDays }}d</div>
</div>
<div class="fdrd-stat">
<div class="fdrd-stat-l">Maturity Date</div>
<div class="fdrd-stat-v">{{ maturityDate | date: 'dd-MM-yyyy' }}</div>
</div>
<div class="fdrd-stat fdrd-stat-good">
<div class="fdrd-stat-l">Maturity Amount</div>
<div class="fdrd-stat-v">&#8377; {{ maturityAmount | number: '1.2-2' }}</div>
</div>
</div>
</div>
<!-- FOOTER -->
<div class="fdrd-foot">
<div class="fdrd-foot-l">
<fa-icon icon="info-circle"></fa-icon>&nbsp; Demo entry — quarterly-compounded interest, no real account is
created.
</div>
<div class="fdrd-foot-actions">
<button mat-stroked-button class="fdrd-btn-ghost" (click)="resetForm()">
<fa-icon icon="undo"></fa-icon>&nbsp; Reset
</button>
<button mat-stroked-button class="fdrd-btn-save" [disabled]="!isValid" (click)="save()">
<fa-icon icon="save"></fa-icon>&nbsp; Save
</button>
</div>
</div>
</div>
@@ -0,0 +1,395 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
:host {
display: block;
font-family: 'Hanken Grotesk', Inter, sans-serif;
color: #1a2a3e;
}
.fdrd-dlg {
background: #fff;
border-radius: 0;
display: flex;
flex-direction: column;
overflow: hidden;
height: 100vh;
}
/* HEADER - Matches .ws-header in clerk dashboard */
.fdrd-head {
padding: 0 12px;
height: 32px;
background: linear-gradient(90deg, #0d1b2e 0%, #162542 100%);
border-bottom: 2px solid #e87722;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-shrink: 0;
}
.fdrd-head-left {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.fdrd-head-ic {
width: 22px;
height: 22px;
border-radius: 6px;
background: #e87722;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
flex-shrink: 0;
}
.fdrd-title {
margin: 0;
font-family: Newsreader, Georgia, serif;
font-size: 14px;
font-weight: 500;
color: #e8f0fb;
letter-spacing: 0.01em;
}
.fdrd-sub {
display: none; /* Hide subtitle in header to match clean style */
}
.fdrd-head-actions {
display: flex;
align-items: center;
gap: 12px;
}
.fdrd-meta {
display: inline-flex;
align-items: center;
font-size: 11px;
color: #7a95b0;
background: transparent;
border: none;
padding: 0;
fa-icon {
color: #5a7589;
margin-right: 4px;
}
}
.fdrd-close {
background: transparent;
border: none;
color: #7a95b0;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 4px;
transition: all 0.15s;
&:hover {
background: rgb(255 255 255 / 10%);
color: #fff;
}
}
/* BODY - Styled like canvas + card grid */
.fdrd-body {
padding: 16px 20px;
overflow-y: auto;
flex: 1;
background: #f0f4f9;
}
.fdrd-success {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 16px;
border-radius: 10px;
background: #dcfce7;
color: #15803d;
margin-bottom: 14px;
font-size: 13px;
fa-icon {
font-size: 16px;
margin-top: 1px;
}
}
.fdrd-success-sub {
margin-top: 2px;
font-size: 11px;
font-weight: 400;
opacity: 0.85;
}
/* Card container for form */
.fdrd-section {
background: #fff;
border: 1px solid #dce4ef;
border-radius: 10px;
padding: 16px 20px;
margin-bottom: 16px;
}
/* Transaction Entry Style Layout mappings */
.ws-group-row {
display: flex;
align-items: stretch;
gap: 0;
&--mt {
margin-top: 12px;
}
}
.ws-group {
display: flex;
flex-direction: column;
gap: 4px;
flex-shrink: 0;
&--flex {
flex: 1;
min-width: 0;
}
&__fields {
display: flex;
align-items: flex-end;
gap: 8px;
flex-wrap: nowrap;
height: 100%;
align-content: flex-end;
}
}
.ws-caption {
font-family: Newsreader, Georgia, serif;
font-style: italic;
font-size: 11px;
color: #7a95b0;
padding-bottom: 2px;
}
.ws-vdivider {
width: 1px;
background: #dce4ef;
margin: 12px 10px 0;
flex-shrink: 0;
}
.ws-f {
display: flex;
flex-direction: column;
gap: 3px;
flex-shrink: 0;
&__lbl {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #7a95b0;
white-space: nowrap;
}
&__ctl {
height: 26px;
padding: 0 7px;
border: 1px solid #c8d8eb;
border-radius: 6px;
font-family: 'Hanken Grotesk', Inter, sans-serif;
font-size: 11.5px;
color: #1a2a3e;
background: #f4f7fc;
box-sizing: border-box;
width: 100%;
transition:
border-color 0.15s,
box-shadow 0.15s;
&:hover {
border-color: #9ab5d0;
}
&:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgb(37 99 235 / 12%);
background: #fff;
}
&--mono {
font-family: 'Roboto Mono', monospace;
font-size: 11.5px;
letter-spacing: 0.03em;
}
}
}
.fdrd-tenure-row {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
.ws-f__ctl {
width: 100%;
min-width: 0;
}
span {
font-size: 10px;
font-weight: 600;
color: #7a95b0;
text-transform: uppercase;
}
}
/* SUMMARY - Styled like info summary block */
.fdrd-summary {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
padding: 12px 16px;
background: #f4f7fc;
border: 1px solid #c8d8eb;
border-radius: 10px;
margin-top: 16px;
}
.fdrd-stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.fdrd-stat-l {
font-size: 10px;
font-weight: 600;
color: #7a95b0;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.fdrd-stat-v {
font-size: 14px;
font-weight: 700;
color: #1a2a3e;
}
.fdrd-stat-good .fdrd-stat-v {
color: #1a7a4a;
}
/* FOOTER - Styled like action panel */
.fdrd-foot {
padding: 10px 16px;
border-top: 1px solid #dce4ef;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
background: #f0f4f9;
}
.fdrd-foot-l {
font-size: 11px;
color: #7a95b0;
display: flex;
align-items: center;
gap: 4px;
}
.fdrd-foot-actions {
display: flex;
gap: 8px;
}
.fdrd-btn-ghost {
display: inline-flex;
align-items: center;
gap: 4px;
height: 28px;
padding: 0 12px !important;
border-radius: 7px;
border: 1px solid #dce4ef !important;
background: #fff !important;
color: #1a2a3e !important;
font-family: 'Hanken Grotesk', sans-serif;
font-size: 11.5px !important;
font-weight: 600 !important;
cursor: pointer;
box-shadow: none !important;
&:hover {
background: #f0f4f9 !important;
border-color: #a8bdd4 !important;
}
}
.fdrd-btn-save {
display: inline-flex;
align-items: center;
gap: 4px;
height: 28px;
padding: 0 16px !important;
border-radius: 7px;
border: none !important;
background: #e87722 !important;
color: #fff !important;
font-family: 'Hanken Grotesk', sans-serif;
font-size: 11.5px !important;
font-weight: 700 !important;
cursor: pointer;
box-shadow: 0 2px 8px rgb(232 119 34 / 30%) !important;
&:hover:not(:disabled) {
filter: brightness(1.08);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
background: #c8d8eb !important;
box-shadow: none !important;
}
}
@media (width <= 768px) {
.ws-group-row {
flex-direction: column;
gap: 12px;
}
.ws-vdivider {
display: none;
}
.ws-group__fields {
flex-wrap: wrap;
}
.fdrd-summary {
grid-template-columns: repeat(2, 1fr);
}
}
@@ -0,0 +1,146 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { CommonModule, DecimalPipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatSnackBar } from '@angular/material/snack-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
type DepositType = 'FD-SINGLE' | 'FD-JOINT' | 'RD-MONTHLY' | 'RD-QUARTERLY';
interface DemoClient {
id: string;
name: string;
}
@Component({
selector: 'mifosx-fd-rd-entry-dialog',
standalone: true,
templateUrl: './fd-rd-entry-dialog.component.html',
styleUrls: ['./fd-rd-entry-dialog.component.scss'],
imports: [CommonModule, DecimalPipe, FormsModule, MatDialogModule, MatButtonModule, MatTooltipModule, FaIconComponent]
})
export class FdRdEntryDialogComponent {
private dialogRef = inject(MatDialogRef<FdRdEntryDialogComponent>);
private snackBar = inject(MatSnackBar);
bank = {
name: 'CBSNexora Bank Ltd',
branch: 'Head Office'
};
depositTypes: { code: DepositType; label: string; defaultRate: number }[] = [
{ code: 'FD-SINGLE', label: 'Fixed Deposit — Single A/c', defaultRate: 7.25 },
{ code: 'FD-JOINT', label: 'Fixed Deposit — Joint A/c', defaultRate: 7.25 },
{ code: 'RD-MONTHLY', label: 'Recurring Deposit — Monthly', defaultRate: 7.0 },
{ code: 'RD-QUARTERLY', label: 'Recurring Deposit — Quarterly', defaultRate: 7.0 }
];
clients: DemoClient[] = [
{ id: 'C-10231', name: 'Ramesh Kumar Sharma' },
{ id: 'C-10344', name: 'Priya Enterprises' },
{ id: 'C-10412', name: 'Anjali Verma' },
{ id: 'C-10567', name: 'Suresh & Sons HUF' },
{ id: 'C-10689', name: 'Vikram Traders' }
];
payModes = ['Cash', 'Cheque', 'Transfer from Savings A/c'];
interestPayoutOptions = ['Cumulative (Paid on Maturity)', 'Monthly Payout', 'Quarterly Payout'];
maturityInstructions = [
'Renew Principal & Interest',
'Renew Principal Only, Payout Interest',
'Payout on Maturity (No Renewal)'
];
depositType: DepositType = 'FD-SINGLE';
clientId = '';
principal: number | null = null;
interestRate = 7.25;
tenureYears = 1;
tenureMonths = 0;
tenureDays = 0;
interestPayout = this.interestPayoutOptions[0];
payMode = this.payModes[0];
nomineeName = '';
maturityInstruction = this.maturityInstructions[0];
saved = false;
lastAccountNo = '';
lastClientName = '';
get selectedClient(): DemoClient | undefined {
return this.clients.find((c) => c.id === this.clientId);
}
onDepositTypeChange(): void {
const dt = this.depositTypes.find((d) => d.code === this.depositType);
if (dt) this.interestRate = dt.defaultRate;
}
get isRecurring(): boolean {
return this.depositType.startsWith('RD');
}
get tenureInYears(): number {
return this.tenureYears + this.tenureMonths / 12 + this.tenureDays / 365;
}
get maturityDate(): Date {
const d = new Date();
d.setFullYear(d.getFullYear() + this.tenureYears);
d.setMonth(d.getMonth() + this.tenureMonths);
d.setDate(d.getDate() + this.tenureDays);
return d;
}
/** Quarterly-compounded maturity value, the standard FD/RD convention */
get maturityAmount(): number {
const p = this.principal || 0;
const t = this.tenureInYears;
if (p <= 0 || t <= 0) return 0;
return p * Math.pow(1 + this.interestRate / 100 / 4, 4 * t);
}
get isValid(): boolean {
return !!this.clientId && (this.principal ?? 0) > 0 && this.tenureInYears > 0;
}
save(): void {
if (!this.isValid) return;
const prefix = this.isRecurring ? 'RD' : 'FD';
this.lastAccountNo = `${prefix}${Math.floor(1000000 + Math.random() * 8999999)}`;
this.lastClientName = this.selectedClient?.name ?? '';
this.saved = true;
this.snackBar.open(`${prefix} account ${this.lastAccountNo} created for ${this.lastClientName}`, 'Close', {
duration: 4000
});
}
resetForm(): void {
this.depositType = 'FD-SINGLE';
this.clientId = '';
this.principal = null;
this.interestRate = 7.25;
this.tenureYears = 1;
this.tenureMonths = 0;
this.tenureDays = 0;
this.nomineeName = '';
this.interestPayout = this.interestPayoutOptions[0];
this.payMode = this.payModes[0];
this.maturityInstruction = this.maturityInstructions[0];
this.saved = false;
}
close(): void {
this.dialogRef.close();
}
}
@@ -63,7 +63,7 @@ export class PslReportDialogComponent {
activeTab: 'dashboard' | 'categories' | 'register' | 'returns' = 'dashboard';
bank = {
name: 'Bharat Urban Bank Ltd',
name: 'CBSNexora Bank Ltd',
branch: 'Head Office',
quarter: 'Q3 FY 2025-26',
asOnDate: '29-01-2026',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,472 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">TREASURY · INVESTMENT · FIXED DEPOSIT</div>
<h1 class="pg-title">FD Investment</h1>
<p class="pg-sub">
Bank's own Fixed Deposit investments with other banks — Purchase, Interest, Sell/Premature, Write-off and
Part-Payment entries.
</p>
</div>
</header>
<div class="cctr-tabs">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'holdings'"
(click)="setTab('holdings')"
>
Holdings
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'purchase'"
(click)="setTab('purchase')"
>
01 Purchase Entry
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'interest'"
(click)="setTab('interest')"
>
02 Interest Entry
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'sell'"
(click)="setTab('sell')"
>
03 Sell / Premature Entry
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'writeoff'"
(click)="setTab('writeoff')"
>
04 Write Off Entry
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'partpayment'"
(click)="setTab('partpayment')"
>
05 FD Part Payment Entry
</button>
</div>
<!-- HOLDINGS -->
@if (activeTab === 'holdings') {
<div class="pg-card">
<div class="pos-section-label">FD Holdings Register</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Party</th>
<th>Type</th>
<th class="num">Face Value</th>
<th class="num">Rate</th>
<th>Purchase Date</th>
<th>Maturity Date</th>
<th class="num col-in">Accrued Interest</th>
<th class="num col-net">Maturity Value</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (h of holdings; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.party }}</td>
<td>{{ h.interestFreq === 'CUMULATIVE' ? 'Cumulative' : 'Payout' }}</td>
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
<td class="num">{{ h.interestRate }}%</td>
<td>{{ h.purchaseDate }}</td>
<td>{{ h.maturityDate }}</td>
<td class="num col-in">₹{{ accrued(h) | number: '1.2-2' }}</td>
<td class="num col-net">₹{{ maturity(h) | number: '1.2-2' }}</td>
<td>
<span [class]="statusClass(h)">{{ h.status }}</span>
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- PURCHASE ENTRY -->
@if (activeTab === 'purchase') {
<div class="pg-card">
<div class="pos-section-label">Investment - FD Purchase Entry</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Tran. Type</span>
<select [(ngModel)]="purchaseForm.tranType">
<option value="FRESH">FRESH</option>
<option value="RENEWAL">RENEWAL</option>
</select>
</label>
@if (purchaseForm.tranType === 'RENEWAL') {
<label class="inv-form-field"
><span>OLD CertNo</span><input type="text" [(ngModel)]="purchaseForm.oldCertNo"
/></label>
}
<label class="inv-form-field">
<span>FD Type</span>
<select [(ngModel)]="purchaseForm.fdType">
@for (t of fdTypeOptions; track t) {
<option [value]="t">{{ t }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Mature Amt.calc.Type</span>
<select [(ngModel)]="purchaseForm.matureAmtCalcType">
<option value="GENERAL">GENERAL</option>
<option value="DATEWISE">DATEWISE</option>
</select>
</label>
<label class="inv-form-field">
<span>Party *</span>
<select [(ngModel)]="purchaseForm.party">
<option value="">-- Select --</option>
@for (p of partyOptions; track p.partyCode) {
<option [value]="p.partyName">{{ p.partyName }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Sub Party</span>
<select [(ngModel)]="purchaseForm.subParty">
<option value="">-- Select --</option>
@for (p of partyOptions; track p.partyCode) {
<option [value]="p.partyName">{{ p.partyName }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>GL ACNO</span><input type="text" [(ngModel)]="purchaseForm.glAcNo"
/></label>
<label class="inv-form-field"
><span>GL A/c Name</span><input type="text" [(ngModel)]="purchaseForm.glAcName" readonly
/></label>
<label class="inv-form-field"
><span>Certificate No.</span><input type="text" [(ngModel)]="purchaseForm.certNo"
/></label>
<label class="inv-form-field"
><span>Certificate Date</span><input type="text" [(ngModel)]="purchaseForm.certDate" placeholder="DD-MM-YYYY"
/></label>
<label class="inv-form-field">
<span>Sub Type</span>
<select [(ngModel)]="purchaseForm.subType">
@for (s of subTypeOptions; track s.subTypeCode + s.subTypeName) {
<option [value]="s.subTypeName">{{ s.subTypeCode }} - {{ s.subTypeName }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Reciept/Advice</span>
<select [(ngModel)]="purchaseForm.receiptAdvice">
<option value="RECEIPT">RECEIPT</option>
<option value="ADVICE">ADVICE</option>
</select>
</label>
<label class="inv-form-field"
><span>Transaction Date</span
><input type="text" [(ngModel)]="purchaseForm.transactionDate" placeholder="DD-MM-YYYY"
/></label>
<label class="inv-form-field"
><span>Deposit Date *</span
><input type="text" [(ngModel)]="purchaseForm.depositDate" placeholder="DD-MM-YYYY"
/></label>
<label class="inv-form-field"
><span>Deposit Amount *</span><input type="number" [(ngModel)]="purchaseForm.depositAmount" min="0"
/></label>
<label class="inv-form-field">
<span>Interest[M/Q/H/Y]</span>
<select [(ngModel)]="purchaseForm.interestFreq">
@for (i of intTypeOptions; track i.value) {
<option [value]="i.value">{{ i.label }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Interest Date</span
><input type="text" [(ngModel)]="purchaseForm.interestDate" placeholder="DD-MM-YYYY"
/></label>
<label class="inv-form-field"
><span>Interest Rate *</span><input type="number" [(ngModel)]="purchaseForm.interestRate" min="0" step="0.01"
/></label>
@if (purchaseForm.matureAmtCalcType === 'GENERAL') {
<label class="inv-form-field"
><span>Period Month</span><input type="number" [(ngModel)]="purchaseForm.periodMonth" min="0" max="11"
/></label>
<label class="inv-form-field"
><span>Year</span><input type="number" [(ngModel)]="purchaseForm.periodYear" min="0"
/></label>
<label class="inv-form-field"
><span>Days</span><input type="number" [(ngModel)]="purchaseForm.periodDays" min="0"
/></label>
} @else {
<label class="inv-form-field"
><span>Maturity Date (manual) *</span
><input type="text" [(ngModel)]="purchaseForm.manualMaturityDate" placeholder="DD-MM-YYYY"
/></label>
}
<label class="inv-form-field"
><span>Resolution No.</span><input type="text" [(ngModel)]="purchaseForm.resolutionNo"
/></label>
<label class="inv-form-field">
<span>CR. Branch Code</span>
<select [(ngModel)]="purchaseForm.crBranchCode">
@for (b of branchOptions; track b) {
<option [value]="b">{{ b }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>CR. To A/c</span><input type="text" [(ngModel)]="purchaseForm.crToAc"
/></label>
<label class="inv-form-field"
><span>CR. A/c Name</span><input type="text" [(ngModel)]="purchaseForm.crToAcName" readonly
/></label>
<label class="inv-form-field"
><span>Int.Receivable Code</span><input type="text" [(ngModel)]="purchaseForm.intReceivableCode"
/></label>
<label class="inv-form-field">
<span>LIEN Y/N</span>
<select [(ngModel)]="purchaseForm.lien">
<option value="NO">NO</option>
<option value="YES">YES</option>
</select>
</label>
@if (purchaseForm.lien === 'YES') {
<label class="inv-form-field"
><span>LIEN AMT.</span><input type="number" [(ngModel)]="purchaseForm.lienAmt"
/></label>
<label class="inv-form-field">
<span>LIEN Reason</span>
<select [(ngModel)]="purchaseForm.lienReason">
<option value="">-- Select --</option>
@for (l of lienReasonOptions; track l.reasonCode) {
<option [value]="l.reasonDescription">{{ l.reasonDescription }}</option>
}
</select>
</label>
}
<label class="inv-form-field">
<span>Classification</span>
<select [(ngModel)]="purchaseForm.classification">
<option value="">-- Select --</option>
<option value="STANDARD">STANDARD</option>
<option value="SUB-STANDARD">SUB-STANDARD</option>
<option value="DOUBTFUL">DOUBTFUL</option>
</select>
</label>
<label class="inv-form-field">
<span>Collable</span>
<select [(ngModel)]="purchaseForm.collable">
<option value="NO">NO</option>
<option value="YES">YES</option>
</select>
</label>
</div>
<div class="pos-section-label">Mature / Premature Details</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>* Maturity Date</span><input type="text" [value]="purchaseMaturityDate" readonly
/></label>
<label class="inv-form-field"
><span>* Maturity Amt</span><input type="text" [value]="purchaseMaturityAmount | number: '1.0-0'" readonly
/></label>
<label class="inv-form-field"
><span>Search by Cert No</span><input type="text" [(ngModel)]="searchCertNo" placeholder="Enter Cert No"
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="savePurchase()">Save</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelPurchase()">Cancel</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="searchPurchase()">Search</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="dropPurchase()">Drop</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="exitPurchase()">Exit</button>
</div>
</div>
}
<!-- INTEREST ENTRY -->
@if (activeTab === 'interest') {
<div class="pg-card">
<div class="pos-section-label">Investment - FD Interest Entry / Calculation</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Certificate No *</span>
<select [(ngModel)]="interestCertNo">
<option value="">-- Select --</option>
@for (h of activeHoldings; track h.certNo) {
<option [value]="h.certNo">{{ h.certNo }} — {{ h.party }}</option>
}
</select>
</label>
@if (interestHolding; as h) {
<label class="inv-form-field"><span>Party</span><input type="text" [value]="h.party" readonly /></label>
<label class="inv-form-field"
><span>Interest Rate</span><input type="text" [value]="h.interestRate + '%'" readonly
/></label>
<label class="inv-form-field"
><span>Frequency</span><input type="text" [value]="h.interestFreq" readonly
/></label>
<label class="inv-form-field"
><span>Accrued Interest (as on {{ asOnDate }})</span
><input type="text" [value]="'₹' + (interestAccrued(h) | number: '1.2-2')" readonly
/></label>
}
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="postInterest()">Process / Post Interest</button>
</div>
</div>
}
<!-- SELL / PREMATURE ENTRY -->
@if (activeTab === 'sell') {
<div class="pg-card">
<div class="pos-section-label">Investment - FD Sell / Premature Closure Entry</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Certificate No *</span>
<select [(ngModel)]="sellCertNo">
<option value="">-- Select --</option>
@for (h of activeHoldings; track h.certNo) {
<option [value]="h.certNo">{{ h.certNo }} — {{ h.party }}</option>
}
</select>
</label>
@if (sellHolding; as h) {
<label class="inv-form-field"
><span>Face Value</span><input type="text" [value]="'₹' + (h.faceValue | number: '1.2-2')" readonly
/></label>
<label class="inv-form-field"
><span>Accrued Interest till Sell Date</span
><input type="text" [value]="'₹' + (accrued(h) | number: '1.2-2')" readonly
/></label>
}
<label class="inv-form-field"
><span>Sell / Premature Date *</span><input type="text" [(ngModel)]="sellDate" placeholder="DD-MM-YYYY"
/></label>
<label class="inv-form-field"><span>Remarks</span><input type="text" [(ngModel)]="sellRemark" /></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="saveSell()">Save</button>
</div>
</div>
}
<!-- WRITE OFF ENTRY -->
@if (activeTab === 'writeoff') {
<div class="pg-card">
<div class="pos-section-label">Investment - FD Write Off Entry</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Certificate No *</span>
<select [(ngModel)]="writeoffCertNo">
<option value="">-- Select --</option>
@for (h of activeHoldings; track h.certNo) {
<option [value]="h.certNo">{{ h.certNo }} — {{ h.party }}</option>
}
</select>
</label>
@if (writeoffHolding; as h) {
<label class="inv-form-field"
><span>Face Value to be Written Off</span
><input type="text" [value]="'₹' + (h.faceValue | number: '1.2-2')" readonly
/></label>
}
<label class="inv-form-field"><span>Reason *</span><input type="text" [(ngModel)]="writeoffReason" /></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="saveWriteoff()">Save</button>
</div>
</div>
}
<!-- PART PAYMENT ENTRY -->
@if (activeTab === 'partpayment') {
<div class="pg-card">
<div class="pos-section-label">Investment - FD Part Payment Entry</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Certificate No *</span>
<select [(ngModel)]="partPayCertNo">
<option value="">-- Select --</option>
@for (h of activeHoldings; track h.certNo) {
<option [value]="h.certNo">{{ h.certNo }} — {{ h.party }}</option>
}
</select>
</label>
@if (partPayHolding; as h) {
<label class="inv-form-field"
><span>Current Face Value</span><input type="text" [value]="'₹' + (h.faceValue | number: '1.2-2')" readonly
/></label>
}
<label class="inv-form-field"
><span>Part Payment Amount *</span><input type="number" [(ngModel)]="partPayAmount" min="0"
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="savePartPayment()">Save</button>
</div>
</div>
}
@if (activeTab !== 'holdings') {
<div class="pg-card">
<div class="pos-section-label">FD Transaction Log</div>
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>Voucher No</th>
<th>Cert No</th>
<th>Type</th>
<th>Particulars</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (t of txns; track t.voucherNo) {
<tr>
<td>{{ t.txnDate }}</td>
<td>
<span class="pg-chip">{{ t.voucherNo }}</span>
</td>
<td>{{ t.certNo }}</td>
<td>{{ t.txnType }}</td>
<td>{{ t.particulars }}</td>
<td class="num">₹{{ t.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
@@ -0,0 +1,461 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import {
FD_HOLDINGS,
ALL_TXNS,
PARTY_MASTER,
SUBTYPE_MASTER,
FD_TYPE_OPTIONS,
INT_TYPE_OPTIONS,
LIEN_REASON_MASTER,
InvestmentHolding,
InvestmentTxn,
InterestFreq,
accruedInterest,
daysToMaturity,
maturityAmount,
addPeriod,
todayDDMMYYYY
} from '../investment-data';
type FdTab = 'holdings' | 'purchase' | 'interest' | 'sell' | 'writeoff' | 'partpayment';
interface FdPurchaseForm {
tranType: 'FRESH' | 'RENEWAL';
oldCertNo: string;
fdType: string;
/** GENERAL uses the period (Y/M/D) fields; DATEWISE lets the user type the maturity date directly. */
matureAmtCalcType: 'GENERAL' | 'DATEWISE';
certNo: string;
certDate: string;
party: string;
subParty: string;
glAcNo: string;
glAcName: string;
subType: string;
receiptAdvice: 'RECEIPT' | 'ADVICE';
transactionDate: string;
depositDate: string;
depositAmount: number | null;
interestFreq: InterestFreq;
interestDate: string;
interestRate: number | null;
periodYear: number | null;
periodMonth: number | null;
periodDays: number | null;
/** Only used when matureAmtCalcType === 'DATEWISE'. */
manualMaturityDate: string;
resolutionNo: string;
crBranchCode: string;
crToAc: string;
crToAcName: string;
intReceivableCode: string;
lien: 'YES' | 'NO';
lienAmt: number | null;
lienReason: string;
classification: string;
collable: 'YES' | 'NO';
}
/**
* FD (Fixed Deposit) treasury investment screen — Holdings register + Purchase / Interest /
* Sell-Premature / Write-off / Part-Payment entry tabs, modelled on the legacy "Investment - FD
* Purchase Entry" form. Dummy in-memory data only; Save appends to the local holdings/txns
* arrays so the demo feels interactive without any backend.
*/
@Component({
selector: 'mifosx-fd-investment',
templateUrl: './fd-investment.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../investment-shared.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule
]
})
export class FdInvestmentComponent {
activeTab: FdTab = 'holdings';
asOnDate = todayDDMMYYYY();
holdings: InvestmentHolding[] = [...FD_HOLDINGS];
txns: InvestmentTxn[] = ALL_TXNS.filter((t) => t.instrumentType === 'FD');
readonly partyOptions = PARTY_MASTER;
readonly subTypeOptions = SUBTYPE_MASTER;
readonly fdTypeOptions = FD_TYPE_OPTIONS;
readonly intTypeOptions = INT_TYPE_OPTIONS;
readonly lienReasonOptions = LIEN_REASON_MASTER;
readonly branchOptions = [
'HEAD OFFICE',
'BRANCH 1',
'BRANCH 2'
];
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
purchaseForm: FdPurchaseForm = this.blankPurchaseForm();
/** Certificate No typed into the Purchase Entry's Search box (legacy "Search" button). */
searchCertNo = '';
interestCertNo = '';
sellCertNo = '';
sellDate = this.asOnDate;
sellRemark = '';
writeoffCertNo = '';
writeoffReason = '';
partPayCertNo = '';
partPayAmount: number | null = null;
setTab(tab: FdTab): void {
this.activeTab = tab;
}
get activeHoldings(): InvestmentHolding[] {
return this.holdings.filter((h) => h.status === 'ACTIVE');
}
private nextCertNo(): string {
const nums = this.holdings.map((h) => parseInt(h.certNo, 10)).filter((n) => !isNaN(n));
return String((nums.length ? Math.max(...nums) : 15619) + 1);
}
private blankPurchaseForm(): FdPurchaseForm {
return {
tranType: 'FRESH',
oldCertNo: '',
fdType: 'CUMMULATIVE',
matureAmtCalcType: 'GENERAL',
certNo: this.nextCertNo(),
certDate: this.asOnDate,
party: '',
subParty: '',
glAcNo: '320020',
glAcName: 'FIXED DEPO. CO-OPER. BANK MAIN',
subType: 'NON SLR',
receiptAdvice: 'RECEIPT',
transactionDate: this.asOnDate,
depositDate: this.asOnDate,
depositAmount: null,
interestFreq: 'CUMULATIVE',
interestDate: this.asOnDate,
interestRate: null,
periodYear: 2,
periodMonth: 0,
periodDays: 0,
manualMaturityDate: '',
resolutionNo: '',
crBranchCode: 'HEAD OFFICE',
crToAc: '310070',
crToAcName: 'CURRENT A/C CO-OP. BANK MAIN BR.41',
intReceivableCode: '320020',
lien: 'NO',
lienAmt: null,
lienReason: '',
classification: '',
collable: 'NO'
};
}
get purchaseMaturityDate(): string {
const f = this.purchaseForm;
if (f.matureAmtCalcType === 'DATEWISE') {
return f.manualMaturityDate;
}
if (!f.depositDate) {
return '';
}
const base = addPeriod(f.depositDate, f.periodYear || 0, f.periodMonth || 0);
return f.periodDays ? addPeriod(base, 0, 0, f.periodDays) : base;
}
get purchaseMaturityAmount(): number {
const f = this.purchaseForm;
if (!f.depositAmount || !f.interestRate || !this.purchaseMaturityDate) {
return 0;
}
return maturityAmount({
certNo: f.certNo,
instrumentType: 'FD',
scriptCode: '',
scriptName: '',
party: f.party,
subType: f.subType,
faceValue: f.depositAmount,
purchaseRate: 100,
units: 1,
purchaseDate: f.depositDate,
maturityDate: this.purchaseMaturityDate,
interestRate: f.interestRate,
interestFreq: f.interestFreq,
glCode: f.glAcNo,
glName: '',
crToAc: f.crToAc,
brokerCode: '',
status: 'ACTIVE'
});
}
savePurchase(): void {
const f = this.purchaseForm;
if (!f.party || !f.depositAmount || !f.interestRate || !this.purchaseMaturityDate) {
this.toast('Please fill Party, Deposit Amount, Interest Rate and Period.');
return;
}
const holding: InvestmentHolding = {
certNo: f.certNo,
instrumentType: 'FD',
scriptCode: f.interestFreq === 'CUMULATIVE' ? 'FD-CUM' : 'FD-MIP',
scriptName: f.fdType ? `${f.fdType} Fixed Deposit` : 'Fixed Deposit',
party: f.party,
subType: f.subType,
faceValue: f.depositAmount,
purchaseRate: 100,
units: 1,
purchaseDate: f.depositDate,
maturityDate: this.purchaseMaturityDate,
interestRate: f.interestRate,
interestFreq: f.interestFreq,
glCode: f.glAcNo,
glName: f.glAcName,
crToAc: f.crToAc,
brokerCode: '',
status: 'ACTIVE'
};
this.holdings = [
holding,
...this.holdings
];
this.txns = [
{
certNo: holding.certNo,
instrumentType: 'FD',
txnType: 'PURCHASE',
voucherNo: `INV/PUR/FD-${holding.certNo}`,
txnDate: f.transactionDate,
amount: holding.faceValue,
particulars: `${f.tranType === 'RENEWAL' ? 'Renewal' : 'Fresh'} FD Purchase - ${holding.party}`
},
...this.txns
];
this.toast(`FD Certificate No. ${holding.certNo} saved successfully.`);
this.purchaseForm = this.blankPurchaseForm();
this.activeTab = 'holdings';
}
cancelPurchase(): void {
this.purchaseForm = this.blankPurchaseForm();
this.toast('Entry cancelled — form reset.');
}
/** Legacy "Search" button — loads an existing certificate's values back into the form. */
searchPurchase(): void {
const h = this.holdings.find((x) => x.certNo === this.searchCertNo.trim());
if (!h) {
this.toast(`Certificate No. ${this.searchCertNo} not found.`);
return;
}
this.purchaseForm = {
...this.blankPurchaseForm(),
tranType: 'FRESH',
certNo: h.certNo,
certDate: h.purchaseDate,
party: h.party,
subType: h.subType,
glAcNo: h.glCode,
glAcName: h.glName,
transactionDate: h.purchaseDate,
depositDate: h.purchaseDate,
depositAmount: h.faceValue,
interestFreq: h.interestFreq,
interestDate: h.purchaseDate,
interestRate: h.interestRate,
matureAmtCalcType: 'DATEWISE',
manualMaturityDate: h.maturityDate,
crToAc: h.crToAc
};
this.toast(`Certificate ${h.certNo} loaded.`);
}
/** Legacy "Drop" button — removes the loaded certificate from the register. */
dropPurchase(): void {
const certNo = this.purchaseForm.certNo;
const exists = this.holdings.some((h) => h.certNo === certNo);
if (!exists) {
this.toast('Nothing to drop — this certificate is not yet saved.');
return;
}
this.holdings = this.holdings.filter((h) => h.certNo !== certNo);
this.txns = this.txns.filter((t) => t.certNo !== certNo);
this.purchaseForm = this.blankPurchaseForm();
this.toast(`Certificate ${certNo} dropped.`);
}
/** Legacy "Exit" button — closes the entry form and returns to the register. */
exitPurchase(): void {
this.purchaseForm = this.blankPurchaseForm();
this.activeTab = 'holdings';
}
// -------------------- Interest --------------------
get interestHolding(): InvestmentHolding | undefined {
return this.holdings.find((h) => h.certNo === this.interestCertNo);
}
interestAccrued(h: InvestmentHolding): number {
return accruedInterest(h, this.asOnDate);
}
postInterest(): void {
const h = this.interestHolding;
if (!h) {
this.toast('Select a certificate first.');
return;
}
const amt = this.interestAccrued(h);
this.txns = [
{
certNo: h.certNo,
instrumentType: 'FD',
txnType: 'INTEREST',
voucherNo: `INV/INT/FD-${h.certNo}-${this.txns.length + 1}`,
txnDate: this.asOnDate,
amount: amt,
particulars: `Interest posted - ${h.party} (${h.interestFreq})`
},
...this.txns
];
this.toast(`Interest of ₹${amt.toLocaleString('en-IN')} posted for Certificate ${h.certNo}.`);
this.interestCertNo = '';
}
// -------------------- Sell / Premature --------------------
get sellHolding(): InvestmentHolding | undefined {
return this.holdings.find((h) => h.certNo === this.sellCertNo);
}
saveSell(): void {
const h = this.sellHolding;
if (!h || !this.sellDate) {
this.toast('Select a certificate and sell/premature date.');
return;
}
const payout = h.faceValue + accruedInterest(h, this.sellDate);
h.status = 'SOLD';
this.txns = [
{
certNo: h.certNo,
instrumentType: 'FD',
txnType: 'SELL',
voucherNo: `INV/SEL/FD-${h.certNo}`,
txnDate: this.sellDate,
amount: payout,
particulars: `Premature closure - ${h.party}${this.sellRemark ? ' - ' + this.sellRemark : ''}`
},
...this.txns
];
this.toast(`Certificate ${h.certNo} closed. Payout ₹${payout.toLocaleString('en-IN')}.`);
this.sellCertNo = '';
this.sellRemark = '';
this.activeTab = 'holdings';
}
// -------------------- Write-off --------------------
get writeoffHolding(): InvestmentHolding | undefined {
return this.holdings.find((h) => h.certNo === this.writeoffCertNo);
}
saveWriteoff(): void {
const h = this.writeoffHolding;
if (!h || !this.writeoffReason) {
this.toast('Select a certificate and enter a write-off reason.');
return;
}
h.status = 'WRITTEN_OFF';
this.txns = [
{
certNo: h.certNo,
instrumentType: 'FD',
txnType: 'WRITE_OFF',
voucherNo: `INV/WOF/FD-${h.certNo}`,
txnDate: this.asOnDate,
amount: h.faceValue,
particulars: `Written off - ${this.writeoffReason}`
},
...this.txns
];
this.toast(`Certificate ${h.certNo} written off.`);
this.writeoffCertNo = '';
this.writeoffReason = '';
this.activeTab = 'holdings';
}
// -------------------- Part Payment --------------------
get partPayHolding(): InvestmentHolding | undefined {
return this.holdings.find((h) => h.certNo === this.partPayCertNo);
}
savePartPayment(): void {
const h = this.partPayHolding;
if (!h || !this.partPayAmount || this.partPayAmount <= 0 || this.partPayAmount >= h.faceValue) {
this.toast('Select a certificate and enter a valid part-payment amount (less than face value).');
return;
}
h.faceValue -= this.partPayAmount;
this.txns = [
{
certNo: h.certNo,
instrumentType: 'FD',
txnType: 'PART_PAYMENT',
voucherNo: `INV/PP/FD-${h.certNo}-${this.txns.length + 1}`,
txnDate: this.asOnDate,
amount: this.partPayAmount,
particulars: `Part payment withdrawn - ${h.party}, balance ₹${h.faceValue.toLocaleString('en-IN')}`
},
...this.txns
];
this.toast(`Part payment of ₹${this.partPayAmount.toLocaleString('en-IN')} recorded for Certificate ${h.certNo}.`);
this.partPayCertNo = '';
this.partPayAmount = null;
this.activeTab = 'holdings';
}
// -------------------- Helpers --------------------
accrued(h: InvestmentHolding): number {
return accruedInterest(h, this.asOnDate);
}
maturity(h: InvestmentHolding): number {
return maturityAmount(h);
}
daysLeft(h: InvestmentHolding): number {
return daysToMaturity(h, this.asOnDate);
}
statusClass(h: InvestmentHolding): string {
return `inv-badge inv-badge--${h.status.toLowerCase()}`;
}
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,146 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">TREASURY · INVESTMENT</div>
<h1 class="pg-title">Investment Portfolio Dashboard</h1>
<p class="pg-sub">
Consolidated position across FD, Govt Security, Non-SLR, Bonds, Mutual Fund and Share holdings as on
{{ asOnDate }}.
</p>
</div>
</header>
<div class="inv-kpis">
<div class="inv-kpi">
<span class="inv-kpi__label">Total Holdings</span>
<span class="inv-kpi__value">{{ totalHoldings }}</span>
<span class="inv-kpi__sub">{{ activeCount }} active · {{ maturedCount }} matured</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Total Invested (Face Value)</span>
<span class="inv-kpi__value">₹{{ totalFaceValue | number: '1.2-2' }}</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Accrued Interest / Income</span>
<span class="inv-kpi__value col-in">₹{{ totalAccruedInterest | number: '1.2-2' }}</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Projected Value at Maturity</span>
<span class="inv-kpi__value">₹{{ totalMaturityValue | number: '1.2-2' }}</span>
</div>
</div>
<div class="pg-card">
<div class="pos-section-label">Instrument-wise Break-up</div>
<table class="pg-table">
<thead>
<tr>
<th>Instrument Type</th>
<th class="num">Holdings</th>
<th class="num">Face Value</th>
<th class="num col-in">Accrued Interest</th>
<th class="num col-net">Maturity Value</th>
<th></th>
</tr>
</thead>
<tbody>
@for (s of summaries; track s.type) {
<tr>
<td>{{ s.label }}</td>
<td class="num">{{ s.count }}</td>
<td class="num">₹{{ s.faceValue | number: '1.2-2' }}</td>
<td class="num col-in">₹{{ s.accruedInterest | number: '1.2-2' }}</td>
<td class="num col-net">₹{{ s.maturityValue | number: '1.2-2' }}</td>
<td>
<a class="pg-btn pg-btn--ghost" [routerLink]="s.route">Open</a>
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td>Total</td>
<td class="num">{{ totalHoldings }}</td>
<td class="num">₹{{ totalFaceValue | number: '1.2-2' }}</td>
<td class="num">₹{{ totalAccruedInterest | number: '1.2-2' }}</td>
<td class="num">₹{{ totalMaturityValue | number: '1.2-2' }}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
<div class="pg-card">
<div class="pos-section-label">Due-Date Diary — Maturing Within 90 Days</div>
@if (maturingSoon.length === 0) {
<div class="pg-empty">No holdings maturing in the next 90 days.</div>
} @else {
<div>
@for (h of maturingSoon; track h.certNo) {
<div class="inv-maturity-row">
<span>
<span class="pg-chip">{{ h.certNo }}</span>
{{ h.scriptName }} — {{ h.party }} ({{ instrumentLabel(h.instrumentType) }})
</span>
<span>
Matures {{ h.maturityDate }} ·
<span
class="inv-maturity-days"
[class.inv-maturity-days--soon]="daysLeft(h) <= 30"
[class.inv-maturity-days--ok]="daysLeft(h) > 30"
>
{{ daysLeft(h) }} days
</span>
</span>
</div>
}
</div>
}
</div>
<div class="pg-card">
<div class="pos-section-label">Quick Entry</div>
<div class="inv-nav-grid" style="padding: 16px">
<a class="inv-nav-card" routerLink="/investment/fd">
<span class="inv-nav-card__label">Fixed Deposit</span>
<span class="inv-nav-card__count">{{ summaries[0].count }}</span>
<span class="inv-nav-card__value">Purchase / Interest / Sell / Write-off</span>
</a>
<a class="inv-nav-card" routerLink="/investment/bonds">
<span class="inv-nav-card__label">Govt Security / Non-SLR / Bonds / MF</span>
<span class="inv-nav-card__count">{{
summaries[1].count + summaries[2].count + summaries[3].count + summaries[4].count
}}</span>
<span class="inv-nav-card__value">Purchase / Interest / Sell / Amortize</span>
</a>
<a class="inv-nav-card" routerLink="/investment/share">
<span class="inv-nav-card__label">Share Entry</span>
<span class="inv-nav-card__count">{{ summaries[5].count }}</span>
<span class="inv-nav-card__value">Purchase / Dividend / Sell / Write-off</span>
</a>
<a class="inv-nav-card" routerLink="/investment/interest">
<span class="inv-nav-card__label">Interest</span>
<span class="inv-nav-card__count">5</span>
<span class="inv-nav-card__value">FD / Bond Calculation &amp; Registers</span>
</a>
<a class="inv-nav-card" routerLink="/investment/masters">
<span class="inv-nav-card__label">Masters</span>
<span class="inv-nav-card__count">10</span>
<span class="inv-nav-card__value">Script / Broker / Party / Control Params</span>
</a>
<a class="inv-nav-card" routerLink="/investment/reports">
<span class="inv-nav-card__label">Reports</span>
<span class="inv-nav-card__count">20+</span>
<span class="inv-nav-card__value">Balance / Ledger / Maturity / Portfolio</span>
</a>
</div>
</div>
</div>
@@ -0,0 +1,120 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import {
ALL_HOLDINGS,
INSTRUMENT_LABELS,
InstrumentType,
InvestmentHolding,
accruedInterest,
daysToMaturity,
maturityAmount
} from '../investment-data';
interface InstrumentSummary {
type: InstrumentType;
label: string;
route: string;
count: number;
faceValue: number;
accruedInterest: number;
maturityValue: number;
}
/** Cross-instrument portfolio dashboard — every figure is derived from ALL_HOLDINGS, never
* hand-duplicated, so it can never drift from the per-instrument screens or Reports. */
@Component({
selector: 'mifosx-investment-dashboard',
templateUrl: './investment-dashboard.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../investment-shared.scss'
],
standalone: true,
imports: [
CommonModule,
RouterLink
]
})
export class InvestmentDashboardComponent {
asOnDate = '';
private readonly types: Array<{ type: InstrumentType; route: string }> = [
{ type: 'FD', route: '/investment/fd' },
{ type: 'GOVT_SECURITY', route: '/investment/bonds' },
{ type: 'NON_SLR', route: '/investment/bonds' },
{ type: 'BOND', route: '/investment/bonds' },
{ type: 'MUTUAL_FUND', route: '/investment/bonds' },
{ type: 'SHARE', route: '/investment/share' }
];
constructor() {
const d = new Date();
this.asOnDate = `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
}
private holdingsOf(type: InstrumentType): InvestmentHolding[] {
return ALL_HOLDINGS.filter((h) => h.instrumentType === type);
}
get summaries(): InstrumentSummary[] {
return this.types.map(({ type, route }) => {
const holdings = this.holdingsOf(type);
return {
type,
label: INSTRUMENT_LABELS[type],
route,
count: holdings.length,
faceValue: holdings.reduce((s, h) => s + h.faceValue, 0),
accruedInterest: holdings.reduce((s, h) => s + accruedInterest(h, this.asOnDate), 0),
maturityValue: holdings.reduce((s, h) => s + maturityAmount(h), 0)
};
});
}
get totalHoldings(): number {
return ALL_HOLDINGS.length;
}
get totalFaceValue(): number {
return ALL_HOLDINGS.reduce((s, h) => s + h.faceValue, 0);
}
get totalAccruedInterest(): number {
return ALL_HOLDINGS.reduce((s, h) => s + accruedInterest(h, this.asOnDate), 0);
}
get totalMaturityValue(): number {
return ALL_HOLDINGS.reduce((s, h) => s + maturityAmount(h), 0);
}
get activeCount(): number {
return ALL_HOLDINGS.filter((h) => h.status === 'ACTIVE').length;
}
get maturedCount(): number {
return ALL_HOLDINGS.filter((h) => h.status === 'MATURED').length;
}
/** Holdings maturing within the next 90 days — a "due-date diary" style widget. */
get maturingSoon(): InvestmentHolding[] {
return ALL_HOLDINGS.filter((h) => h.status === 'ACTIVE' && daysToMaturity(h, this.asOnDate) <= 90).sort(
(a, b) => daysToMaturity(a, this.asOnDate) - daysToMaturity(b, this.asOnDate)
);
}
daysLeft(h: InvestmentHolding): number {
return daysToMaturity(h, this.asOnDate);
}
instrumentLabel(type: InstrumentType): string {
return INSTRUMENT_LABELS[type];
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,352 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">TREASURY · INVESTMENT · INTEREST</div>
<h1 class="pg-title">Investment Interest</h1>
<p class="pg-sub">
FD and Govt./Bond interest calculation batch runs, plus the Government Bond Interest, FD Interest and FD
registers.
</p>
</div>
</header>
<div class="cctr-tabs">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'fdCalc'"
(click)="setTab('fdCalc')"
>
01 FD Interest Calculation
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'bondCalc'"
(click)="setTab('bondCalc')"
>
02 Govt./Bond Int. Calculation
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'bondIntRegister'"
(click)="setTab('bondIntRegister')"
>
03 Government Bond Int Register
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'fdIntRegister'"
(click)="setTab('fdIntRegister')"
>
04 FD Interest Register
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'fdRegister'"
(click)="setTab('fdRegister')"
>
05 FD Register
</button>
</div>
<!-- 01 FD INTEREST CALCULATION -->
@if (activeTab === 'fdCalc') {
<div class="pg-card">
<div class="pos-section-label">Investment - FD Interest Calculation</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Date</span><input type="text" [(ngModel)]="fdCalcDate" placeholder="DD-MM-YYYY"
/></label>
<label class="inv-form-field">
<span>Certificate No</span>
<select [(ngModel)]="fdCalcCertNo">
<option value="">-- All Certificates --</option>
@for (h of fdHoldings; track h.certNo) {
<option [value]="h.certNo">{{ h.certNo }} — {{ h.party }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>ALL</span><input type="checkbox" [(ngModel)]="fdCalcAll" style="height: 18px; width: 18px"
/></label>
<label class="inv-form-field"
><span>Total Interest to Post</span
><input type="text" [value]="'₹' + (fdCalcTotal | number: '1.2-2')" readonly
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="processFdInterest()">Process</button>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Party</th>
<th class="num">Face Value</th>
<th class="num">Rate</th>
<th>Frequency</th>
<th class="num col-in">Interest</th>
</tr>
</thead>
<tbody>
@for (h of fdCalcTargets; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.party }}</td>
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
<td class="num">{{ h.interestRate }}%</td>
<td>{{ h.interestFreq }}</td>
<td class="num col-in">₹{{ fdInterest(h) | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="pg-empty">No FD certificates match the filter.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="5">Total</td>
<td class="num">₹{{ fdCalcTotal | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
<!-- 02 GOVT./BOND INT. CALCULATION -->
@if (activeTab === 'bondCalc') {
<div class="pg-card">
<div class="pos-section-label">Investment - Govt./Bond Interest Calculation</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Date</span><input type="text" [(ngModel)]="bondCalcDate" placeholder="DD-MM-YYYY"
/></label>
<label class="inv-form-field">
<span>Type</span>
<select [(ngModel)]="bondCalcType">
<option value="">-- All Types --</option>
<option value="GOVT_SECURITY">GOVT SECURITY</option>
<option value="NON_SLR">NON SLR</option>
<option value="BOND">BOND</option>
</select>
</label>
<label class="inv-form-field">
<span>Script</span>
<select [(ngModel)]="bondCalcScript">
<option value="">-- All Scripts --</option>
@for (s of scriptOptions; track s.scriptCode) {
<option [value]="s.scriptCode">{{ s.scriptCode }} — {{ s.scriptName }}</option>
}
</select>
</label>
<label class="inv-form-field"><span>Deal No</span><input type="text" [(ngModel)]="bondCalcDealNo" /></label>
<label class="inv-form-field"
><span>ALL</span><input type="checkbox" [(ngModel)]="bondCalcAll" style="height: 18px; width: 18px"
/></label>
<label class="inv-form-field"
><span>Total Interest to Post</span
><input type="text" [value]="'₹' + (bondCalcTotal | number: '1.2-2')" readonly
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="processBondInterest()">Process</button>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script</th>
<th>Type</th>
<th class="num">Face Value</th>
<th class="num">Rate</th>
<th>Frequency</th>
<th class="num col-in">Interest</th>
</tr>
</thead>
<tbody>
@for (h of bondCalcTargets; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName }}</td>
<td>{{ instrumentLabel(h.instrumentType) }}</td>
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
<td class="num">{{ h.interestRate }}%</td>
<td>{{ h.interestFreq }}</td>
<td class="num col-in">₹{{ bondInterest(h) | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="pg-empty">No holdings match the filter.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="6">Total</td>
<td class="num">₹{{ bondCalcTotal | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
<!-- 03 GOVERNMENT BOND INT REGISTER -->
@if (activeTab === 'bondIntRegister') {
<div class="pg-card">
<div class="pos-section-label">Government Bond Interest Register</div>
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>Voucher No</th>
<th>Cert No</th>
<th>Instrument</th>
<th>Particulars</th>
<th class="num col-in">Interest Amount</th>
</tr>
</thead>
<tbody>
@for (t of bondIntRegister; track t.voucherNo) {
<tr>
<td>{{ t.txnDate }}</td>
<td>
<span class="pg-chip">{{ t.voucherNo }}</span>
</td>
<td>{{ t.certNo }}</td>
<td>{{ instrumentLabel(t.instrumentType) }}</td>
<td>{{ t.particulars }}</td>
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="pg-empty">No bond interest entries recorded yet.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="5">Total</td>
<td class="num">₹{{ bondIntRegisterTotal | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
<!-- 04 FD INTEREST REGISTER -->
@if (activeTab === 'fdIntRegister') {
<div class="pg-card">
<div class="pos-section-label">FD Interest Register</div>
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>Voucher No</th>
<th>Cert No</th>
<th>Particulars</th>
<th class="num col-in">Interest Amount</th>
</tr>
</thead>
<tbody>
@for (t of fdIntRegister; track t.voucherNo) {
<tr>
<td>{{ t.txnDate }}</td>
<td>
<span class="pg-chip">{{ t.voucherNo }}</span>
</td>
<td>{{ t.certNo }}</td>
<td>{{ t.particulars }}</td>
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="pg-empty">No FD interest entries recorded yet.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="4">Total</td>
<td class="num">₹{{ fdIntRegisterTotal | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
<!-- 05 FD REGISTER -->
@if (activeTab === 'fdRegister') {
<div class="pg-card">
<div class="pos-section-label">FD Register</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Party</th>
<th>Sub Type</th>
<th class="num">Face Value</th>
<th class="num">Rate</th>
<th>Frequency</th>
<th>Deposit Date</th>
<th>Maturity Date</th>
<th class="num">Days Left</th>
<th class="num col-in">Accrued Interest</th>
<th class="num col-net">Maturity Value</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (h of fdRegister; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.party }}</td>
<td>{{ h.subType }}</td>
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
<td class="num">{{ h.interestRate }}%</td>
<td>{{ h.interestFreq }}</td>
<td>{{ h.purchaseDate }}</td>
<td>{{ h.maturityDate }}</td>
<td class="num">{{ daysLeft(h) }}</td>
<td class="num col-in">₹{{ accrued(h) | number: '1.2-2' }}</td>
<td class="num col-net">₹{{ maturity(h) | number: '1.2-2' }}</td>
<td>
<span [class]="statusClass(h)">{{ h.status }}</span>
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="3">Total</td>
<td class="num">₹{{ fdRegisterFaceTotal | number: '1.2-2' }}</td>
<td colspan="5"></td>
<td class="num">₹{{ fdRegisterAccruedTotal | number: '1.2-2' }}</td>
<td class="num">₹{{ fdRegisterMaturityTotal | number: '1.2-2' }}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
}
</div>
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
@@ -0,0 +1,247 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import {
FD_HOLDINGS,
GOVT_SECURITY_HOLDINGS,
NON_SLR_HOLDINGS,
BOND_HOLDINGS,
ALL_TXNS,
SCRIPT_MASTER,
InvestmentHolding,
InvestmentTxn,
InstrumentType,
INSTRUMENT_LABELS,
accruedInterest,
daysToMaturity,
maturityAmount,
todayDDMMYYYY
} from '../investment-data';
type InterestTab = 'fdCalc' | 'bondCalc' | 'bondIntRegister' | 'fdIntRegister' | 'fdRegister';
/**
* The legacy "Interest" top-menu for Investment — 5 screens:
* 01 FD Interest Calculation, 02 Govt./Bond Int. Calculation (both batch "Process" runs),
* 03 Government Bond Int Register, 04 FD Interest Register, 05 FD Register (listings).
*
* 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.
*/
@Component({
selector: 'mifosx-investment-interest',
templateUrl: './investment-interest.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../investment-shared.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule
]
})
export class InvestmentInterestComponent {
activeTab: InterestTab = 'fdCalc';
asOnDate = todayDDMMYYYY();
fdHoldings: InvestmentHolding[] = [...FD_HOLDINGS];
bondHoldings: InvestmentHolding[] = [
...GOVT_SECURITY_HOLDINGS,
...NON_SLR_HOLDINGS,
...BOND_HOLDINGS
];
txns: InvestmentTxn[] = [...ALL_TXNS];
readonly scriptOptions = SCRIPT_MASTER;
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
// 01 FD Interest Calculation
fdCalcDate = this.asOnDate;
fdCalcCertNo = '';
fdCalcAll = false;
// 02 Govt./Bond Int. Calculation
bondCalcDate = this.asOnDate;
bondCalcType: InstrumentType | '' = '';
bondCalcScript = '';
bondCalcDealNo = '';
bondCalcAll = false;
// Register filters
registerFromDate = '';
registerToDate = '';
setTab(tab: InterestTab): void {
this.activeTab = tab;
}
instrumentLabel(type: InstrumentType): string {
return INSTRUMENT_LABELS[type];
}
statusClass(h: InvestmentHolding): string {
return `inv-badge inv-badge--${h.status.toLowerCase()}`;
}
maturity(h: InvestmentHolding): number {
return maturityAmount(h);
}
daysLeft(h: InvestmentHolding): number {
return daysToMaturity(h, this.asOnDate);
}
// -------------------- 01 FD Interest Calculation --------------------
get fdCalcTargets(): InvestmentHolding[] {
if (this.fdCalcAll) {
return this.fdHoldings.filter((h) => h.status === 'ACTIVE');
}
return this.fdHoldings.filter(
(h) => h.status === 'ACTIVE' && (!this.fdCalcCertNo || h.certNo === this.fdCalcCertNo)
);
}
fdInterest(h: InvestmentHolding): number {
return accruedInterest(h, this.fdCalcDate || this.asOnDate);
}
get fdCalcTotal(): number {
return this.fdCalcTargets.reduce((s, h) => s + this.fdInterest(h), 0);
}
processFdInterest(): void {
const targets = this.fdCalcTargets;
if (targets.length === 0) {
this.toast('No FD holdings match the selected filters.');
return;
}
const newTxns: InvestmentTxn[] = targets.map((h, i) => ({
certNo: h.certNo,
instrumentType: 'FD',
txnType: 'INTEREST',
voucherNo: `INV/FD-INT/${(this.fdCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`,
txnDate: this.fdCalcDate || this.asOnDate,
amount: this.fdInterest(h),
particulars: `FD interest calculated - ${h.party}`
}));
this.txns = [
...newTxns,
...this.txns
];
this.toast(
`FD interest processed for ${targets.length} certificate(s). Total ₹${this.fdCalcTotal.toLocaleString('en-IN')}.`
);
}
// -------------------- 02 Govt./Bond Int. Calculation --------------------
get bondCalcTargets(): InvestmentHolding[] {
if (this.bondCalcAll) {
return this.bondHoldings.filter((h) => h.status === 'ACTIVE');
}
return this.bondHoldings.filter((h) => {
if (h.status !== 'ACTIVE') return false;
if (this.bondCalcType && h.instrumentType !== this.bondCalcType) return false;
if (this.bondCalcScript && h.scriptCode !== this.bondCalcScript) return false;
if (this.bondCalcDealNo && h.certNo !== this.bondCalcDealNo.trim()) return false;
return true;
});
}
bondInterest(h: InvestmentHolding): number {
return accruedInterest(h, this.bondCalcDate || this.asOnDate);
}
get bondCalcTotal(): number {
return this.bondCalcTargets.reduce((s, h) => s + this.bondInterest(h), 0);
}
processBondInterest(): void {
const targets = this.bondCalcTargets;
if (targets.length === 0) {
this.toast('No Govt/Bond holdings match the selected filters.');
return;
}
const newTxns: InvestmentTxn[] = targets.map((h, i) => ({
certNo: h.certNo,
instrumentType: h.instrumentType,
txnType: 'INTEREST',
voucherNo: `INV/BOND-INT/${(this.bondCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`,
txnDate: this.bondCalcDate || this.asOnDate,
amount: this.bondInterest(h),
particulars: `Coupon/interest calculated - ${h.scriptName}`
}));
this.txns = [
...newTxns,
...this.txns
];
this.toast(
`Interest processed for ${targets.length} holding(s). Total ₹${this.bondCalcTotal.toLocaleString('en-IN')}.`
);
}
// -------------------- 03 Government Bond Int Register --------------------
get bondIntRegister(): InvestmentTxn[] {
return this.txns.filter(
(t) => t.txnType === 'INTEREST' && ([
'GOVT_SECURITY',
'NON_SLR',
'BOND'
] as InstrumentType[]).includes(t.instrumentType)
);
}
get bondIntRegisterTotal(): number {
return this.bondIntRegister.reduce((s, t) => s + t.amount, 0);
}
// -------------------- 04 FD Interest Register --------------------
get fdIntRegister(): InvestmentTxn[] {
return this.txns.filter((t) => t.txnType === 'INTEREST' && t.instrumentType === 'FD');
}
get fdIntRegisterTotal(): number {
return this.fdIntRegister.reduce((s, t) => s + t.amount, 0);
}
// -------------------- 05 FD Register --------------------
get fdRegister(): InvestmentHolding[] {
return this.fdHoldings;
}
get fdRegisterFaceTotal(): number {
return this.fdRegister.reduce((s, h) => s + h.faceValue, 0);
}
get fdRegisterMaturityTotal(): number {
return this.fdRegister.reduce((s, h) => s + this.maturity(h), 0);
}
accrued(h: InvestmentHolding): number {
return accruedInterest(h, this.asOnDate);
}
get fdRegisterAccruedTotal(): number {
return this.fdRegister.reduce((s, h) => s + this.accrued(h), 0);
}
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,557 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">TREASURY · INVESTMENT · MASTERS</div>
<h1 class="pg-title">Investment Masters</h1>
<p class="pg-sub">
Bank/Party, Script, SubType, Broker, Control Parameter, LIEN Reason, Mutual Fund Product, SGL Bank and Script
Market Detail masters.
</p>
</div>
</header>
<div class="cctr-tabs">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'party'"
(click)="setTab('party')"
>
04 Bank / Party Master
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'script'"
(click)="setTab('script')"
>
05 Script Master
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'subtype'"
(click)="setTab('subtype')"
>
06 SubType Master
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'broker'"
(click)="setTab('broker')"
>
07 Broker Master
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'control'"
(click)="setTab('control')"
>
08 Control Parameter
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'dailyscript'"
(click)="setTab('dailyscript')"
>
09 Daily Script Master
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'lien'"
(click)="setTab('lien')"
>
10 LIEN Reason Master
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'mfproduct'"
(click)="setTab('mfproduct')"
>
11 Mutual Fund Product Master
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'sglbank'"
(click)="setTab('sglbank')"
>
12 SGL Bank Master
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'marketdetail'"
(click)="setTab('marketdetail')"
>
13 Script Market Detail
</button>
</div>
<!-- PARTY MASTER -->
@if (activeTab === 'party') {
<div class="pg-card">
<div class="pos-section-label">Add Bank / Party</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Party Name *</span><input type="text" [(ngModel)]="newParty.partyName"
/></label>
<label class="inv-form-field">
<span>Party Type *</span>
<select [(ngModel)]="newParty.partyType">
<option [ngValue]="undefined">-- Select --</option>
<option value="BANK">BANK</option>
<option value="CORPORATE">CORPORATE</option>
<option value="GOVT">GOVT</option>
<option value="AMC">AMC</option>
<option value="EXCHANGE">EXCHANGE</option>
<option value="COOP_SOCIETY">COOP SOCIETY</option>
</select>
</label>
<label class="inv-form-field"><span>PAN No</span><input type="text" [(ngModel)]="newParty.panNo" /></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="addParty()">Save</button>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Party Code</th>
<th>Party Name</th>
<th>Type</th>
<th>PAN No</th>
</tr>
</thead>
<tbody>
@for (p of parties; track p.partyCode) {
<tr>
<td>
<span class="pg-chip">{{ p.partyCode }}</span>
</td>
<td>{{ p.partyName }}</td>
<td>{{ p.partyType }}</td>
<td>{{ p.panNo }}</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- SCRIPT MASTER -->
@if (activeTab === 'script') {
<div class="pg-card">
<div class="pos-section-label">Add Script</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Script Code</span><input type="text" [(ngModel)]="newScript.scriptCode"
/></label>
<label class="inv-form-field"
><span>Script Name *</span><input type="text" [(ngModel)]="newScript.scriptName"
/></label>
<label class="inv-form-field">
<span>Instrument Type *</span>
<select [(ngModel)]="newScript.instrumentType">
<option [ngValue]="undefined">-- Select --</option>
@for (t of instrumentTypes; track t) {
<option [value]="t">{{ t }}</option>
}
</select>
</label>
<label class="inv-form-field"><span>ISIN</span><input type="text" [(ngModel)]="newScript.isin" /></label>
<label class="inv-form-field"
><span>Face Value / Unit</span><input type="number" [(ngModel)]="newScript.faceValuePerUnit"
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="addScript()">Save</button>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Script Code</th>
<th>Script Name</th>
<th>Instrument</th>
<th>ISIN</th>
<th class="num">Face Value/Unit</th>
</tr>
</thead>
<tbody>
@for (s of scripts; track s.scriptCode) {
<tr>
<td>
<span class="pg-chip">{{ s.scriptCode }}</span>
</td>
<td>{{ s.scriptName }}</td>
<td>{{ s.instrumentType }}</td>
<td>{{ s.isin || '—' }}</td>
<td class="num">{{ s.faceValuePerUnit }}</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- SUBTYPE MASTER -->
@if (activeTab === 'subtype') {
<div class="pg-card">
<div class="pos-section-label">Add Sub Type</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Sub Type Name *</span><input type="text" [(ngModel)]="newSubType.subTypeName"
/></label>
<label class="inv-form-field">
<span>Instrument Type *</span>
<select [(ngModel)]="newSubType.instrumentType">
<option [ngValue]="undefined">-- Select --</option>
@for (t of instrumentTypes; track t) {
<option [value]="t">{{ t }}</option>
}
</select>
</label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="addSubType()">Save</button>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Code</th>
<th>Sub Type</th>
<th>Instrument</th>
</tr>
</thead>
<tbody>
@for (s of subTypes; track s.subTypeCode + s.subTypeName) {
<tr>
<td>
<span class="pg-chip">{{ s.subTypeCode }}</span>
</td>
<td>{{ s.subTypeName }}</td>
<td>{{ s.instrumentType }}</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- BROKER MASTER -->
@if (activeTab === 'broker') {
<div class="pg-card">
<div class="pos-section-label">Add Broker</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Broker Name *</span><input type="text" [(ngModel)]="newBroker.brokerName"
/></label>
<label class="inv-form-field"
><span>Contact No</span><input type="text" [(ngModel)]="newBroker.contactNo"
/></label>
<label class="inv-form-field"
><span>Empanelled On</span><input type="text" [(ngModel)]="newBroker.empanelledOn" placeholder="DD-MM-YYYY"
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="addBroker()">Save</button>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Broker Code</th>
<th>Broker Name</th>
<th>Contact</th>
<th>Empanelled On</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (b of brokers; track b.brokerCode) {
<tr>
<td>
<span class="pg-chip">{{ b.brokerCode }}</span>
</td>
<td>{{ b.brokerName }}</td>
<td>{{ b.contactNo }}</td>
<td>{{ b.empanelledOn }}</td>
<td>
<span class="inv-badge inv-badge--active">{{ b.status }}</span>
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- CONTROL PARAMETER -->
@if (activeTab === 'control') {
<div class="pg-card">
<div class="pos-section-label">Add Control Parameter</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Parameter Name *</span><input type="text" [(ngModel)]="newControlParam.paramName"
/></label>
<label class="inv-form-field"
><span>Value *</span><input type="text" [(ngModel)]="newControlParam.paramValue"
/></label>
<label class="inv-form-field"
><span>Description</span><input type="text" [(ngModel)]="newControlParam.description"
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="addControlParam()">Save</button>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Parameter</th>
<th>Value</th>
<th>Description</th>
</tr>
</thead>
<tbody>
@for (c of controlParams; track c.paramName) {
<tr>
<td>
<span class="pg-chip">{{ c.paramName }}</span>
</td>
<td class="num">{{ c.paramValue }}</td>
<td>{{ c.description }}</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- DAILY SCRIPT MASTER -->
@if (activeTab === 'dailyscript') {
<div class="pg-card">
<div class="pos-section-label">Add Daily Script Rate</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Rate Date *</span><input type="text" [(ngModel)]="newDailyRate.rateDate" placeholder="DD-MM-YYYY"
/></label>
<label class="inv-form-field">
<span>Script Code *</span>
<select [(ngModel)]="newDailyRate.scriptCode">
<option [ngValue]="undefined">-- Select --</option>
@for (s of scripts; track s.scriptCode) {
<option [value]="s.scriptCode">{{ s.scriptCode }} — {{ s.scriptName }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Open Rate</span><input type="number" [(ngModel)]="newDailyRate.openRate" step="0.01"
/></label>
<label class="inv-form-field"
><span>Close Rate *</span><input type="number" [(ngModel)]="newDailyRate.closeRate" step="0.01"
/></label>
<label class="inv-form-field"
><span>High Rate</span><input type="number" [(ngModel)]="newDailyRate.highRate" step="0.01"
/></label>
<label class="inv-form-field"
><span>Low Rate</span><input type="number" [(ngModel)]="newDailyRate.lowRate" step="0.01"
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="addDailyRate()">Save</button>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Rate Date</th>
<th>Script Code</th>
<th>Script Name</th>
<th class="num">Open</th>
<th class="num">High</th>
<th class="num">Low</th>
<th class="num">Close</th>
</tr>
</thead>
<tbody>
@for (r of dailyScriptRates; track r.rateDate + r.scriptCode) {
<tr>
<td>{{ r.rateDate }}</td>
<td>
<span class="pg-chip">{{ r.scriptCode }}</span>
</td>
<td>{{ r.scriptName }}</td>
<td class="num">{{ r.openRate | number: '1.2-2' }}</td>
<td class="num">{{ r.highRate | number: '1.2-2' }}</td>
<td class="num">{{ r.lowRate | number: '1.2-2' }}</td>
<td class="num">{{ r.closeRate | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- LIEN REASON MASTER -->
@if (activeTab === 'lien') {
<div class="pg-card">
<div class="pos-section-label">Add LIEN Reason</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Reason Description *</span><input type="text" [(ngModel)]="newLienReason.reasonDescription"
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="addLienReason()">Save</button>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Reason Code</th>
<th>Description</th>
</tr>
</thead>
<tbody>
@for (l of lienReasons; track l.reasonCode) {
<tr>
<td>
<span class="pg-chip">{{ l.reasonCode }}</span>
</td>
<td>{{ l.reasonDescription }}</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- MUTUAL FUND PRODUCT MASTER -->
@if (activeTab === 'mfproduct') {
<div class="pg-card">
<div class="pos-section-label">Add Mutual Fund Product</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Product Name *</span><input type="text" [(ngModel)]="newMfProduct.productName"
/></label>
<label class="inv-form-field"
><span>AMC Name *</span><input type="text" [(ngModel)]="newMfProduct.amcName"
/></label>
<label class="inv-form-field"
><span>Category</span><input type="text" [(ngModel)]="newMfProduct.category"
/></label>
<label class="inv-form-field">
<span>Risk Level</span>
<select [(ngModel)]="newMfProduct.riskLevel">
<option value="LOW">LOW</option>
<option value="MODERATE">MODERATE</option>
<option value="HIGH">HIGH</option>
</select>
</label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="addMfProduct()">Save</button>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Product Code</th>
<th>Product Name</th>
<th>AMC</th>
<th>Category</th>
<th>Risk</th>
</tr>
</thead>
<tbody>
@for (m of mfProducts; track m.productCode) {
<tr>
<td>
<span class="pg-chip">{{ m.productCode }}</span>
</td>
<td>{{ m.productName }}</td>
<td>{{ m.amcName }}</td>
<td>{{ m.category }}</td>
<td>{{ m.riskLevel }}</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- SGL BANK MASTER -->
@if (activeTab === 'sglbank') {
<div class="pg-card">
<div class="pos-section-label">Add SGL Bank</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Bank Name *</span><input type="text" [(ngModel)]="newSglBank.bankName"
/></label>
<label class="inv-form-field"><span>Branch</span><input type="text" [(ngModel)]="newSglBank.branch" /></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="addSglBank()">Save</button>
</div>
<table class="pg-table">
<thead>
<tr>
<th>SGL Code</th>
<th>Bank Name</th>
<th>Branch</th>
</tr>
</thead>
<tbody>
@for (s of sglBanks; track s.sglCode) {
<tr>
<td>
<span class="pg-chip">{{ s.sglCode }}</span>
</td>
<td>{{ s.bankName }}</td>
<td>{{ s.branch }}</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- SCRIPT MARKET DETAIL (also covers "09 Daily Script Master") -->
@if (activeTab === 'marketdetail') {
<div class="pg-card">
<div class="pos-section-label">Script Market Detail (Daily Script Rates)</div>
<table class="pg-table">
<thead>
<tr>
<th>Script Code</th>
<th class="num">Market Price</th>
<th>As On</th>
<th class="num">YTM (%)</th>
</tr>
</thead>
<tbody>
@for (m of marketDetails; track m.scriptCode) {
<tr>
<td>
<span class="pg-chip">{{ m.scriptCode }}</span>
</td>
<td class="num">₹{{ m.marketPrice | number: '1.2-2' }}</td>
<td>{{ m.asOnDate }}</td>
<td class="num">{{ m.ytm }}%</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
@@ -0,0 +1,262 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import {
SCRIPT_MASTER,
BROKER_MASTER,
PARTY_MASTER,
SUBTYPE_MASTER,
CONTROL_PARAMETERS,
MUTUAL_FUND_PRODUCTS,
SGL_BANK_MASTER,
SCRIPT_MARKET_DETAIL,
DAILY_SCRIPT_RATES,
LIEN_REASON_MASTER,
ScriptMaster,
BrokerMaster,
PartyMaster,
SubTypeMaster,
ControlParameter,
MutualFundProductMaster,
SglBankMaster,
ScriptMarketDetail,
DailyScriptRate,
LienReason,
InstrumentType
} from '../investment-data';
type MasterTab =
| 'party'
| 'script'
| 'subtype'
| 'broker'
| 'control'
| 'dailyscript'
| 'lien'
| 'mfproduct'
| 'sglbank'
| 'marketdetail';
/**
* Investment master-data screens (legacy "04-13" Entry submenu masters) — 9 simple dummy
* lists, each with an inline Add-row form so the demo stays interactive without a backend.
*/
@Component({
selector: 'mifosx-investment-masters',
templateUrl: './investment-masters.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../investment-shared.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule
]
})
export class InvestmentMastersComponent {
activeTab: MasterTab = 'party';
scripts: ScriptMaster[] = [...SCRIPT_MASTER];
brokers: BrokerMaster[] = [...BROKER_MASTER];
parties: PartyMaster[] = [...PARTY_MASTER];
subTypes: SubTypeMaster[] = [...SUBTYPE_MASTER];
controlParams: ControlParameter[] = [...CONTROL_PARAMETERS];
mfProducts: MutualFundProductMaster[] = [...MUTUAL_FUND_PRODUCTS];
sglBanks: SglBankMaster[] = [...SGL_BANK_MASTER];
marketDetails: ScriptMarketDetail[] = [...SCRIPT_MARKET_DETAIL];
dailyScriptRates: DailyScriptRate[] = [...DAILY_SCRIPT_RATES];
lienReasons: LienReason[] = [...LIEN_REASON_MASTER];
readonly instrumentTypes: InstrumentType[] = [
'FD',
'GOVT_SECURITY',
'NON_SLR',
'BOND',
'MUTUAL_FUND',
'SHARE'
];
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
newParty: Partial<PartyMaster> = {};
newScript: Partial<ScriptMaster> = {};
newSubType: Partial<SubTypeMaster> = {};
newBroker: Partial<BrokerMaster> = {};
newControlParam: Partial<ControlParameter> = {};
newLienReason: Partial<LienReason> = {};
newMfProduct: Partial<MutualFundProductMaster> = {};
newSglBank: Partial<SglBankMaster> = {};
newDailyRate: Partial<DailyScriptRate> = {};
setTab(tab: MasterTab): void {
this.activeTab = tab;
}
addParty(): void {
if (!this.newParty.partyName || !this.newParty.partyType) {
this.toast('Enter Party Name and Type.');
return;
}
this.parties = [
{
partyCode: `PTY-${String(this.parties.length + 1).padStart(3, '0')}`,
panNo: '',
...this.newParty
} as PartyMaster,
...this.parties
];
this.newParty = {};
this.toast('Party/Bank master added.');
}
addScript(): void {
if (!this.newScript.scriptName || !this.newScript.instrumentType) {
this.toast('Enter Script Name and Instrument Type.');
return;
}
this.scripts = [
{
scriptCode: this.newScript.scriptCode || `SCR-${this.scripts.length + 1}`,
isin: '',
faceValuePerUnit: 100,
...this.newScript
} as ScriptMaster,
...this.scripts
];
this.newScript = {};
this.toast('Script master added.');
}
addSubType(): void {
if (!this.newSubType.subTypeName || !this.newSubType.instrumentType) {
this.toast('Enter Sub Type Name and Instrument Type.');
return;
}
this.subTypes = [
{ subTypeCode: String(this.subTypes.length + 1), ...this.newSubType } as SubTypeMaster,
...this.subTypes
];
this.newSubType = {};
this.toast('Sub Type master added.');
}
addBroker(): void {
if (!this.newBroker.brokerName) {
this.toast('Enter Broker Name.');
return;
}
this.brokers = [
{
brokerCode: `BRK-${String(this.brokers.length + 1).padStart(3, '0')}`,
contactNo: '',
empanelledOn: '',
status: 'ACTIVE',
...this.newBroker
} as BrokerMaster,
...this.brokers
];
this.newBroker = {};
this.toast('Broker master added.');
}
addControlParam(): void {
if (!this.newControlParam.paramName || !this.newControlParam.paramValue) {
this.toast('Enter Parameter Name and Value.');
return;
}
this.controlParams = [
{ description: '', ...this.newControlParam } as ControlParameter,
...this.controlParams
];
this.newControlParam = {};
this.toast('Control parameter added.');
}
addLienReason(): void {
if (!this.newLienReason.reasonDescription) {
this.toast('Enter Reason Description.');
return;
}
this.lienReasons = [
{ reasonCode: `LR-${String(this.lienReasons.length + 1).padStart(2, '0')}`, ...this.newLienReason } as LienReason,
...this.lienReasons
];
this.newLienReason = {};
this.toast('LIEN reason added.');
}
addMfProduct(): void {
if (!this.newMfProduct.productName || !this.newMfProduct.amcName) {
this.toast('Enter Product Name and AMC Name.');
return;
}
this.mfProducts = [
{
productCode: `MFP-${String(this.mfProducts.length + 1).padStart(2, '0')}`,
category: '',
riskLevel: 'MODERATE',
...this.newMfProduct
} as MutualFundProductMaster,
...this.mfProducts
];
this.newMfProduct = {};
this.toast('Mutual Fund product added.');
}
addSglBank(): void {
if (!this.newSglBank.bankName) {
this.toast('Enter Bank Name.');
return;
}
this.sglBanks = [
{
sglCode: `SGL-${String(this.sglBanks.length + 1).padStart(2, '0')}`,
branch: '',
...this.newSglBank
} as SglBankMaster,
...this.sglBanks
];
this.newSglBank = {};
this.toast('SGL Bank master added.');
}
addDailyRate(): void {
if (!this.newDailyRate.scriptCode || !this.newDailyRate.rateDate || this.newDailyRate.closeRate == null) {
this.toast('Enter Rate Date, Script Code and Close Rate.');
return;
}
const script = this.scripts.find((s) => s.scriptCode === this.newDailyRate.scriptCode);
this.dailyScriptRates = [
{
scriptName: script?.scriptName || '',
openRate: this.newDailyRate.closeRate,
highRate: this.newDailyRate.closeRate,
lowRate: this.newDailyRate.closeRate,
...this.newDailyRate
} as DailyScriptRate,
...this.dailyScriptRates
];
this.newDailyRate = {};
this.toast('Daily script rate added.');
}
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,694 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">TREASURY · INVESTMENT · REPORTS</div>
<h1 class="pg-title">Investment Reports</h1>
<p class="pg-sub">
Balance reports, ledgers, registers, maturity lists and the consolidated portfolio — all derived live from the
same holdings/transactions used by the Entry screens.
</p>
</div>
</header>
<div class="cctr-tabs">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeGroup === 'balance'"
(click)="setGroup('balance', 'govtSecBalance')"
>
Balance Reports
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeGroup === 'ledger'"
(click)="setGroup('ledger', 'fdLedger')"
>
Ledgers &amp; Registers
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeGroup === 'maturity'"
(click)="setGroup('maturity', 'maturedFd')"
>
Maturity &amp; Lists
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeGroup === 'portfolio'"
(click)="setGroup('portfolio', 'portfolio')"
>
Portfolio
</button>
</div>
<!-- ===================== BALANCE REPORTS ===================== -->
@if (activeGroup === 'balance') {
<div class="cctr-tabs">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'govtSecBalance'"
(click)="setReport('govtSecBalance')"
>
Govt Security Balance
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'shareBalance'"
(click)="setReport('shareBalance')"
>
Share Balance
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'fdrBalance'"
(click)="setReport('fdrBalance')"
>
FDR Balance
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'bankInvList'"
(click)="setReport('bankInvList')"
>
Bank Investment List
</button>
</div>
@if (activeReport === 'govtSecBalance' || activeReport === 'shareBalance' || activeReport === 'fdrBalance') {
<div class="pg-card">
<div class="pos-section-label">
{{
activeReport === 'govtSecBalance'
? 'Govt Security Balance Report'
: activeReport === 'shareBalance'
? 'Share Balance Report'
: 'FDR Balance Report'
}}
</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script / Party</th>
<th class="num">Units</th>
<th class="num">Face Value</th>
<th class="num">Market/Purchase Rate</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (
h of activeReport === 'govtSecBalance'
? govtSecHoldings
: activeReport === 'shareBalance'
? shareHoldings
: fdHoldings;
track h.certNo
) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName }} — {{ h.party }}</td>
<td class="num">{{ h.units | number }}</td>
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
<td class="num">{{ marketPrice(h.scriptCode) || h.purchaseRate }}</td>
<td>
<span [class]="statusClass(h)">{{ h.status }}</span>
</td>
</tr>
}
</tbody>
</table>
</div>
}
@if (activeReport === 'bankInvList') {
<div class="pg-card">
<div class="pos-section-label">Bank Investment List</div>
<table class="pg-table">
<thead>
<tr>
<th>Party / Bank</th>
<th class="num">No. of Holdings</th>
<th class="num">Total Face Value</th>
</tr>
</thead>
<tbody>
@for (r of bankInvestmentList; track r.party) {
<tr>
<td>{{ r.party }}</td>
<td class="num">{{ r.count }}</td>
<td class="num">₹{{ r.totalFaceValue | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
}
<!-- ===================== LEDGERS & REGISTERS ===================== -->
@if (activeGroup === 'ledger') {
<div class="cctr-tabs">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'fdLedger'"
(click)="setReport('fdLedger')"
>
FD Ledger
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'bondLedger'"
(click)="setReport('bondLedger')"
>
Govt/Bond Ledger
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'shareLedger'"
(click)="setReport('shareLedger')"
>
Share Ledger
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'intDueRegister'"
(click)="setReport('intDueRegister')"
>
Interest Due Register
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'slrRegister'"
(click)="setReport('slrRegister')"
>
SLR Bond Register
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'pendingReceived'"
(click)="setReport('pendingReceived')"
>
Pending/Received Interest
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'amortizeLedger'"
(click)="setReport('amortizeLedger')"
>
Amortize Ledger
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'dealSlip'"
(click)="setReport('dealSlip')"
>
Deal Slip
</button>
</div>
@if (
activeReport === 'fdLedger' ||
activeReport === 'bondLedger' ||
activeReport === 'shareLedger' ||
activeReport === 'amortizeLedger' ||
activeReport === 'dealSlip'
) {
<div class="pg-card">
<div class="pos-section-label">
{{
activeReport === 'fdLedger'
? 'FD Ledger'
: activeReport === 'bondLedger'
? 'Govt/Bond Ledger'
: activeReport === 'shareLedger'
? 'Share Ledger'
: activeReport === 'amortizeLedger'
? 'Amortize Ledger'
: 'Deal Slip Register'
}}
</div>
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>Voucher No</th>
<th>Cert No</th>
<th>Type</th>
<th>Particulars</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (
t of activeReport === 'fdLedger'
? fdTxns
: activeReport === 'bondLedger'
? bondTxns
: activeReport === 'shareLedger'
? shareTxns
: activeReport === 'amortizeLedger'
? amortizeTxns
: dealSlips;
track t.voucherNo
) {
<tr>
<td>{{ t.txnDate }}</td>
<td>
<span class="pg-chip">{{ t.voucherNo }}</span>
</td>
<td>{{ t.certNo }}</td>
<td>{{ t.txnType }}</td>
<td>{{ t.particulars }}</td>
<td class="num">₹{{ t.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="pg-empty">No entries.</td>
</tr>
}
</tbody>
</table>
</div>
}
@if (activeReport === 'intDueRegister') {
<div class="pg-card">
<div class="pos-section-label">Interest Due Register (Non-Cumulative Holdings)</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script / Party</th>
<th>Frequency</th>
<th class="num">Rate</th>
<th class="num col-in">Due Amount (as on {{ asOnDate }})</th>
</tr>
</thead>
<tbody>
@for (h of interestDueRegister; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName }} — {{ h.party }}</td>
<td>{{ h.interestFreq }}</td>
<td class="num">{{ h.interestRate }}%</td>
<td class="num col-in">₹{{ h.dueAmount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
@if (activeReport === 'slrRegister') {
<div class="pg-card">
<div class="pos-section-label">SLR Bond Register</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script</th>
<th class="num">Face Value</th>
<th class="num">Rate</th>
<th>Maturity Date</th>
</tr>
</thead>
<tbody>
@for (h of slrBondRegister; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName }}</td>
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
<td class="num">{{ h.interestRate }}%</td>
<td>{{ h.maturityDate }}</td>
</tr>
}
</tbody>
</table>
</div>
}
@if (activeReport === 'pendingReceived') {
<div class="pg-card">
<div class="pos-section-label">Received Interest / Dividend</div>
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>Cert No</th>
<th>Particulars</th>
<th class="num col-in">Amount</th>
</tr>
</thead>
<tbody>
@for (t of receivedInterestTxns; track t.voucherNo) {
<tr>
<td>{{ t.txnDate }}</td>
<td>{{ t.certNo }}</td>
<td>{{ t.particulars }}</td>
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
<div class="pg-card">
<div class="pos-section-label">Pending Interest (No Interest Posted Yet)</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script / Party</th>
<th class="num col-out">Accrued (Pending)</th>
</tr>
</thead>
<tbody>
@for (h of pendingInterestHoldings; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName }} — {{ h.party }}</td>
<td class="num col-out">₹{{ accrued(h) | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
}
<!-- ===================== MATURITY & LISTS ===================== -->
@if (activeGroup === 'maturity') {
<div class="cctr-tabs">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'maturedFd'"
(click)="setReport('maturedFd')"
>
List of Matured FD
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'maturingList'"
(click)="setReport('maturingList')"
>
Govt/FD Mature List
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'maturityPattern'"
(click)="setReport('maturityPattern')"
>
Maturity Pattern Register
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'govtSecList'"
(click)="setReport('govtSecList')"
>
Govt Security List
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'fdSecList'"
(click)="setReport('fdSecList')"
>
FD Security List
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'shareSecList'"
(click)="setReport('shareSecList')"
>
Share Security List
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'mfList'"
(click)="setReport('mfList')"
>
Mutual Fund List
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === 'shiftingReport'"
(click)="setReport('shiftingReport')"
>
Shifting Report
</button>
</div>
@if (activeReport === 'maturedFd') {
<div class="pg-card">
<div class="pos-section-label">List of Matured FD</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Party</th>
<th class="num">Face Value</th>
<th>Matured On</th>
</tr>
</thead>
<tbody>
@for (h of maturedFdList; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.party }}</td>
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
<td>{{ h.maturityDate }}</td>
</tr>
} @empty {
<tr>
<td colspan="4" class="pg-empty">No matured FDs.</td>
</tr>
}
</tbody>
</table>
</div>
}
@if (activeReport === 'maturingList') {
<div class="pg-card">
<div class="pos-section-label">Govt Security / FD Maturity List (sorted by days-to-maturity)</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Instrument</th>
<th>Script / Party</th>
<th>Maturity Date</th>
<th class="num">Days Left</th>
<th class="num col-net">Maturity Value</th>
</tr>
</thead>
<tbody>
@for (h of maturingList; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ instrumentLabel(h.instrumentType) }}</td>
<td>{{ h.scriptName }} — {{ h.party }}</td>
<td>{{ h.maturityDate }}</td>
<td class="num">{{ daysLeft(h) }}</td>
<td class="num col-net">₹{{ maturity(h) | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
@if (activeReport === 'maturityPattern') {
<div class="pg-card">
<div class="pos-section-label">Maturity Pattern Register</div>
<table class="pg-table">
<thead>
<tr>
<th>Bucket</th>
<th class="num">Holdings</th>
<th class="num">Face Value</th>
</tr>
</thead>
<tbody>
@for (b of maturityBuckets; track b.label) {
<tr>
<td>{{ b.label }}</td>
<td class="num">{{ b.count }}</td>
<td class="num">₹{{ b.faceValue | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
@if (
activeReport === 'govtSecList' ||
activeReport === 'fdSecList' ||
activeReport === 'shareSecList' ||
activeReport === 'mfList'
) {
<div class="pg-card">
<div class="pos-section-label">
{{
activeReport === 'govtSecList'
? 'Govt Security List'
: activeReport === 'fdSecList'
? 'FD Security List'
: activeReport === 'shareSecList'
? 'Share Security List'
: 'Mutual Fund List'
}}
</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script</th>
<th>Party</th>
<th class="num">Face Value</th>
<th class="num">Rate</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (
h of activeReport === 'govtSecList'
? govtSecList
: activeReport === 'fdSecList'
? fdSecList
: activeReport === 'shareSecList'
? shareSecList
: mutualFundList;
track h.certNo
) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName }}</td>
<td>{{ h.party }}</td>
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
<td class="num">{{ h.interestRate }}%</td>
<td>
<span [class]="statusClass(h)">{{ h.status }}</span>
</td>
</tr>
}
</tbody>
</table>
</div>
}
@if (activeReport === 'shiftingReport') {
<div class="pg-card">
<div class="pos-section-label">
Shifting Report — SLR Securities with Market Deviation ≥ 1% (AFS/HTM review candidates)
</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script</th>
<th class="num">Purchase Rate</th>
<th class="num">Market Price</th>
<th class="num">Deviation %</th>
</tr>
</thead>
<tbody>
@for (h of shiftingCandidates; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName }}</td>
<td class="num">{{ h.purchaseRate }}</td>
<td class="num">{{ h.marketPrice }}</td>
<td class="num" [class.col-in]="h.deviationPct > 0" [class.col-out]="h.deviationPct < 0">
{{ h.deviationPct }}%
</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="pg-empty">No securities crossing the 1% deviation threshold today.</td>
</tr>
}
</tbody>
</table>
</div>
}
}
<!-- ===================== PORTFOLIO ===================== -->
@if (activeGroup === 'portfolio') {
<div class="pg-card">
<div class="pos-section-label">Investment Portfolio — Consolidated Summary</div>
<table class="pg-table">
<thead>
<tr>
<th>Instrument Type</th>
<th class="num">Holdings</th>
<th class="num">Face Value</th>
<th class="num col-in">Accrued Interest</th>
<th class="num col-net">Maturity Value</th>
</tr>
</thead>
<tbody>
@for (r of portfolioSummary; track r.label) {
<tr>
<td>{{ r.label }}</td>
<td class="num">{{ r.count }}</td>
<td class="num">₹{{ r.faceValue | number: '1.2-2' }}</td>
<td class="num col-in">₹{{ r.accrued | number: '1.2-2' }}</td>
<td class="num col-net">₹{{ r.maturityValue | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td>Total</td>
<td class="num"></td>
<td class="num">₹{{ portfolioTotalFaceValue | number: '1.2-2' }}</td>
<td class="num">₹{{ portfolioTotalAccrued | number: '1.2-2' }}</td>
<td class="num">₹{{ portfolioTotalMaturity | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
</div>
@@ -0,0 +1,247 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
ALL_HOLDINGS,
ALL_TXNS,
FD_HOLDINGS,
GOVT_SECURITY_HOLDINGS,
NON_SLR_HOLDINGS,
BOND_HOLDINGS,
MUTUAL_FUND_HOLDINGS,
SHARE_HOLDINGS,
SCRIPT_MARKET_DETAIL,
InstrumentType,
InvestmentHolding,
InvestmentTxn,
INSTRUMENT_LABELS,
accruedInterest,
maturityAmount,
daysToMaturity,
todayDDMMYYYY
} from '../investment-data';
type ReportGroup = 'balance' | 'ledger' | 'maturity' | 'portfolio';
/**
* ~20 legacy Investment reports, grouped into 4 tabs. Every figure here is derived from the
* same ALL_HOLDINGS/ALL_TXNS arrays used by the Entry screens and Dashboard — so a report can
* never drift out of sync with what a Purchase/Sell/Interest entry actually recorded.
*/
@Component({
selector: 'mifosx-investment-reports',
templateUrl: './investment-reports.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../investment-shared.scss'
],
standalone: true,
imports: [CommonModule]
})
export class InvestmentReportsComponent {
activeGroup: ReportGroup = 'balance';
activeReport = 'govtSecBalance';
asOnDate = todayDDMMYYYY();
setGroup(group: ReportGroup, defaultReport: string): void {
this.activeGroup = group;
this.activeReport = defaultReport;
}
setReport(report: string): void {
this.activeReport = report;
}
instrumentLabel(type: InstrumentType): string {
return INSTRUMENT_LABELS[type];
}
accrued(h: InvestmentHolding): number {
return accruedInterest(h, this.asOnDate);
}
maturity(h: InvestmentHolding): number {
return maturityAmount(h);
}
daysLeft(h: InvestmentHolding): number {
return daysToMaturity(h, this.asOnDate);
}
statusClass(h: InvestmentHolding): string {
return `inv-badge inv-badge--${h.status.toLowerCase()}`;
}
marketPrice(scriptCode: string): number | null {
return SCRIPT_MARKET_DETAIL.find((m) => m.scriptCode === scriptCode)?.marketPrice ?? null;
}
// -------------------- Balance Reports --------------------
get govtSecHoldings(): InvestmentHolding[] {
return GOVT_SECURITY_HOLDINGS;
}
get shareHoldings(): InvestmentHolding[] {
return SHARE_HOLDINGS;
}
get fdHoldings(): InvestmentHolding[] {
return FD_HOLDINGS;
}
get bankInvestmentList(): Array<{ party: string; count: number; totalFaceValue: number }> {
const map = new Map<string, { party: string; count: number; totalFaceValue: number }>();
for (const h of ALL_HOLDINGS) {
const row = map.get(h.party) || { party: h.party, count: 0, totalFaceValue: 0 };
row.count += 1;
row.totalFaceValue += h.faceValue;
map.set(h.party, row);
}
return [...map.values()].sort((a, b) => b.totalFaceValue - a.totalFaceValue);
}
// -------------------- Ledgers & Registers --------------------
get fdTxns(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => t.instrumentType === 'FD');
}
get bondTxns(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => ([
'GOVT_SECURITY',
'NON_SLR',
'BOND'
] as InstrumentType[]).includes(t.instrumentType));
}
get shareTxns(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => t.instrumentType === 'SHARE');
}
get amortizeTxns(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => t.txnType === 'AMORTIZE');
}
get dealSlips(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => t.txnType === 'PURCHASE');
}
get slrBondRegister(): InvestmentHolding[] {
return GOVT_SECURITY_HOLDINGS.filter((h) => h.subType === 'SLR');
}
get interestDueRegister(): Array<InvestmentHolding & { dueAmount: number }> {
return ALL_HOLDINGS.filter((h) => h.status === 'ACTIVE' && h.interestFreq !== 'CUMULATIVE').map((h) => ({
...h,
dueAmount: this.accrued(h)
}));
}
get receivedInterestTxns(): InvestmentTxn[] {
return ALL_TXNS.filter((t) => t.txnType === 'INTEREST' || t.txnType === 'DIVIDEND');
}
get pendingInterestHoldings(): InvestmentHolding[] {
const receivedCerts = new Set(this.receivedInterestTxns.map((t) => t.certNo));
return ALL_HOLDINGS.filter(
(h) => h.status === 'ACTIVE' && h.interestFreq !== 'CUMULATIVE' && !receivedCerts.has(h.certNo)
);
}
// -------------------- Maturity & Lists --------------------
get maturedFdList(): InvestmentHolding[] {
return FD_HOLDINGS.filter((h) => h.status === 'MATURED');
}
get maturingList(): InvestmentHolding[] {
return [
...FD_HOLDINGS,
...GOVT_SECURITY_HOLDINGS
]
.filter((h) => h.status === 'ACTIVE')
.sort((a, b) => this.daysLeft(a) - this.daysLeft(b));
}
get maturityBuckets(): Array<{ label: string; count: number; faceValue: number }> {
const buckets = [
{ label: '0-1 Year', min: -99999, max: 365, count: 0, faceValue: 0 },
{ label: '1-3 Years', min: 366, max: 1095, count: 0, faceValue: 0 },
{ label: '3-5 Years', min: 1096, max: 1825, count: 0, faceValue: 0 },
{ label: 'Above 5 Years', min: 1826, max: 999999, count: 0, faceValue: 0 }
];
for (const h of ALL_HOLDINGS.filter((x) => x.status === 'ACTIVE')) {
const days = this.daysLeft(h);
const bucket = buckets.find((b) => days >= b.min && days <= b.max);
if (bucket) {
bucket.count += 1;
bucket.faceValue += h.faceValue;
}
}
return buckets;
}
get govtSecList(): InvestmentHolding[] {
return GOVT_SECURITY_HOLDINGS;
}
get fdSecList(): InvestmentHolding[] {
return FD_HOLDINGS;
}
get shareSecList(): InvestmentHolding[] {
return SHARE_HOLDINGS;
}
get mutualFundList(): InvestmentHolding[] {
return MUTUAL_FUND_HOLDINGS;
}
get nonSlrList(): InvestmentHolding[] {
return NON_SLR_HOLDINGS;
}
get bondList(): InvestmentHolding[] {
return BOND_HOLDINGS;
}
/** Shifting Report — SLR securities whose market value has moved away from book value by
* more than 1%, a proxy for "category shift" candidates (AFS/HTM reclassification review). */
get shiftingCandidates(): Array<InvestmentHolding & { marketPrice: number; deviationPct: number }> {
return GOVT_SECURITY_HOLDINGS.map((h) => {
const mp = this.marketPrice(h.scriptCode);
if (mp === null) {
return null;
}
const deviationPct = +(((mp - h.purchaseRate) / h.purchaseRate) * 100).toFixed(2);
return { ...h, marketPrice: mp, deviationPct };
})
.filter(
(x): x is InvestmentHolding & { marketPrice: number; deviationPct: number } =>
x !== null && Math.abs(x.deviationPct) >= 1
)
.sort((a, b) => Math.abs(b.deviationPct) - Math.abs(a.deviationPct));
}
// -------------------- Portfolio --------------------
private readonly portfolioTypes: InstrumentType[] = [
'FD',
'GOVT_SECURITY',
'NON_SLR',
'BOND',
'MUTUAL_FUND',
'SHARE'
];
get portfolioSummary(): Array<{
label: string;
count: number;
faceValue: number;
accrued: number;
maturityValue: number;
}> {
return this.portfolioTypes.map((type) => {
const holdings = ALL_HOLDINGS.filter((h) => h.instrumentType === type);
return {
label: INSTRUMENT_LABELS[type],
count: holdings.length,
faceValue: holdings.reduce((s, h) => s + h.faceValue, 0),
accrued: holdings.reduce((s, h) => s + this.accrued(h), 0),
maturityValue: holdings.reduce((s, h) => s + this.maturity(h), 0)
};
});
}
get portfolioTotalFaceValue(): number {
return this.portfolioSummary.reduce((s, r) => s + r.faceValue, 0);
}
get portfolioTotalAccrued(): number {
return this.portfolioSummary.reduce((s, r) => s + r.accrued, 0);
}
get portfolioTotalMaturity(): number {
return this.portfolioSummary.reduce((s, r) => s + r.maturityValue, 0);
}
}
@@ -0,0 +1,71 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { InvestmentDashboardComponent } from './investment-dashboard/investment-dashboard.component';
import { FdInvestmentComponent } from './fd-investment/fd-investment.component';
import { BondInvestmentComponent } from './bond-investment/bond-investment.component';
import { ShareInvestmentComponent } from './share-investment/share-investment.component';
import { InvestmentMastersComponent } from './investment-masters/investment-masters.component';
import { InvestmentReportsComponent } from './investment-reports/investment-reports.component';
import { InvestmentInterestComponent } from './investment-interest/investment-interest.component';
const routes: Routes = [
Route.withShell([
{
path: 'investment',
data: { title: 'Investment', breadcrumb: 'Investment' },
children: [
{
path: 'dashboard',
component: InvestmentDashboardComponent,
data: { title: 'Investment Dashboard', breadcrumb: 'Investment Dashboard' }
},
{
path: 'fd',
component: FdInvestmentComponent,
data: { title: 'FD Investment', breadcrumb: 'FD Investment' }
},
{
path: 'bonds',
component: BondInvestmentComponent,
data: { title: 'Govt Security / Bond Investment', breadcrumb: 'Govt Security / Bond Investment' }
},
{
path: 'share',
component: ShareInvestmentComponent,
data: { title: 'Share Investment', breadcrumb: 'Share Investment' }
},
{
path: 'masters',
component: InvestmentMastersComponent,
data: { title: 'Investment Masters', breadcrumb: 'Investment Masters' }
},
{
path: 'interest',
component: InvestmentInterestComponent,
data: { title: 'Investment Interest', breadcrumb: 'Investment Interest' }
},
{
path: 'reports',
component: InvestmentReportsComponent,
data: { title: 'Investment Reports', breadcrumb: 'Investment Reports' }
}
]
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class InvestmentRoutingModule {}
+236
View File
@@ -0,0 +1,236 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
// Extra styles layered on top of ../cashier/cashier-shared.scss (pg-*, cctr-*) for the
// Investment module — instrument nav cards, status badges, KPI tiles, entry-form layout.
.inv-kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
@media (width <= 1100px) {
grid-template-columns: repeat(2, 1fr);
}
}
.inv-kpi {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 4px;
&__label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #64748b;
}
&__value {
font-size: 20px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #0f172a;
}
&__sub {
font-size: 11px;
color: #94a3b8;
}
}
.inv-nav-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.inv-nav-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 16px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
text-decoration: none;
color: inherit;
transition:
border-color 0.15s,
box-shadow 0.15s;
&:hover {
border-color: #0f4f3c;
box-shadow: 0 2px 10px rgb(15 79 60 / 10%);
}
&__label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #64748b;
}
&__count {
font-size: 22px;
font-weight: 800;
color: #0f172a;
}
&__value {
font-size: 13px;
font-weight: 600;
color: #0f4f3c;
}
}
.inv-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
&--active {
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
}
&--matured {
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
&--sold {
background: #fefce8;
color: #a16207;
border: 1px solid #fde68a;
}
&--written_off {
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fecaca;
}
}
.inv-form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px 20px;
padding: 18px;
}
.inv-form-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
font-weight: 600;
color: #64748b;
input,
select {
width: 100%;
height: 36px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
color: #0d1b2a;
font-family: inherit;
background: #fff;
box-sizing: border-box;
}
// Long "code — name (branch)" style option text must never run under the native dropdown
// arrow — truncate it with an ellipsis instead of letting the browser clip it mid-word.
select {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// Computed / lookup-filled fields are rendered read-only and greyed out so they read as
// derived values rather than something the user is expected to type into.
/* stylelint-disable-next-line selector-no-qualifying-type -- [readonly] only applies to inputs */
input[readonly] {
background: #f8fafc;
color: #64748b;
}
}
.inv-form-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
padding: 0 18px 18px;
}
.inv-maturity-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px dotted #eef2f7;
font-size: 13px;
&:last-child {
border-bottom: none;
}
}
.inv-maturity-days {
font-weight: 800;
font-variant-numeric: tabular-nums;
&--soon {
color: #dc2626;
}
&--ok {
color: #16a34a;
}
}
.inv-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: #0f172a;
color: #fff;
padding: 12px 20px;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
box-shadow: 0 8px 24px rgb(0 0 0 / 25%);
opacity: 0;
pointer-events: none;
transition:
opacity 0.2s,
transform 0.2s;
z-index: 2000;
&.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { InvestmentRoutingModule } from './investment-routing.module';
import { InvestmentDashboardComponent } from './investment-dashboard/investment-dashboard.component';
import { FdInvestmentComponent } from './fd-investment/fd-investment.component';
import { BondInvestmentComponent } from './bond-investment/bond-investment.component';
import { ShareInvestmentComponent } from './share-investment/share-investment.component';
import { InvestmentMastersComponent } from './investment-masters/investment-masters.component';
import { InvestmentReportsComponent } from './investment-reports/investment-reports.component';
import { InvestmentInterestComponent } from './investment-interest/investment-interest.component';
@NgModule({
imports: [
SharedModule,
InvestmentRoutingModule,
InvestmentDashboardComponent,
FdInvestmentComponent,
BondInvestmentComponent,
ShareInvestmentComponent,
InvestmentMastersComponent,
InvestmentReportsComponent,
InvestmentInterestComponent
]
})
export class InvestmentModule {}
@@ -0,0 +1,276 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">TREASURY · INVESTMENT · SHARE</div>
<h1 class="pg-title">Share Entry</h1>
<p class="pg-sub">
Equity and cooperative-society share holdings — Purchase, Dividend, Sell and Write-off entries.
</p>
</div>
</header>
<div class="cctr-tabs">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'holdings'"
(click)="setTab('holdings')"
>
Holdings
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'purchase'"
(click)="setTab('purchase')"
>
01 Share Purchase Entry
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'dividend'"
(click)="setTab('dividend')"
>
02 Share Interest / Dividend Entry
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'sell'"
(click)="setTab('sell')"
>
03 Share Sell Entry
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'writeoff'"
(click)="setTab('writeoff')"
>
04 Share Write Off Entry
</button>
</div>
<!-- HOLDINGS -->
@if (activeTab === 'holdings') {
<div class="pg-card">
<div class="pos-section-label">Share Holdings Register</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script</th>
<th>Party</th>
<th class="num">Units</th>
<th class="num">Rate</th>
<th class="num">Face Value</th>
<th>Purchase Date</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (h of holdings; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName }}</td>
<td>{{ h.party }}</td>
<td class="num">{{ h.units | number }}</td>
<td class="num">₹{{ h.purchaseRate | number: '1.2-2' }}</td>
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
<td>{{ h.purchaseDate }}</td>
<td>
<span [class]="statusClass(h)">{{ h.status }}</span>
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- PURCHASE ENTRY -->
@if (activeTab === 'purchase') {
<div class="pg-card">
<div class="pos-section-label">Investment - Share Purchase Entry</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Cert No</span><input type="text" [(ngModel)]="purchaseForm.certNo" readonly
/></label>
<label class="inv-form-field"
><span>Script Code</span><input type="text" [(ngModel)]="purchaseForm.scriptCode"
/></label>
<label class="inv-form-field"
><span>Script/Company Name *</span><input type="text" [(ngModel)]="purchaseForm.scriptName"
/></label>
<label class="inv-form-field"
><span>Party / Exchange</span><input type="text" [(ngModel)]="purchaseForm.party"
/></label>
<label class="inv-form-field">
<span>Sub Type</span>
<select [(ngModel)]="purchaseForm.subType">
<option value="EQUITY">EQUITY</option>
<option value="CO-OP SHARE">CO-OP SHARE</option>
</select>
</label>
<label class="inv-form-field"
><span>No. of Units *</span><input type="number" [(ngModel)]="purchaseForm.units" min="0"
/></label>
<label class="inv-form-field"
><span>Purchase Rate *</span><input type="number" [(ngModel)]="purchaseForm.purchaseRate" min="0" step="0.01"
/></label>
<label class="inv-form-field"
><span>Purchase Date *</span
><input type="text" [(ngModel)]="purchaseForm.purchaseDate" placeholder="DD-MM-YYYY"
/></label>
<label class="inv-form-field"
><span>Broker Code</span><input type="text" [(ngModel)]="purchaseForm.brokerCode"
/></label>
<label class="inv-form-field"
><span>CR To A/c</span><input type="text" [(ngModel)]="purchaseForm.crToAc"
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelPurchase()">Cancel</button>
<button type="button" class="pg-btn pg-btn--primary" (click)="savePurchase()">Save</button>
</div>
</div>
}
<!-- DIVIDEND ENTRY -->
@if (activeTab === 'dividend') {
<div class="pg-card">
<div class="pos-section-label">Investment - Share Interest / Dividend Entry</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Cert No *</span>
<select [(ngModel)]="dividendCertNo">
<option value="">-- Select --</option>
@for (h of activeHoldings; track h.certNo) {
<option [value]="h.certNo">{{ h.certNo }} — {{ h.scriptName }}</option>
}
</select>
</label>
@if (dividendHolding; as h) {
<label class="inv-form-field"><span>Units Held</span><input type="text" [value]="h.units" readonly /></label>
}
<label class="inv-form-field"
><span>Dividend per Share *</span><input type="number" [(ngModel)]="dividendPerShare" min="0" step="0.01"
/></label>
<label class="inv-form-field"
><span>Total Dividend (computed)</span
><input type="text" [value]="'₹' + (dividendAmount | number: '1.2-2')" readonly
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="saveDividend()">Save</button>
</div>
</div>
}
<!-- SELL ENTRY -->
@if (activeTab === 'sell') {
<div class="pg-card">
<div class="pos-section-label">Investment - Share Sell Entry</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Cert No *</span>
<select [(ngModel)]="sellCertNo">
<option value="">-- Select --</option>
@for (h of activeHoldings; track h.certNo) {
<option [value]="h.certNo">{{ h.certNo }} — {{ h.scriptName }}</option>
}
</select>
</label>
@if (sellHolding; as h) {
<label class="inv-form-field"><span>Units Held</span><input type="text" [value]="h.units" readonly /></label>
}
<label class="inv-form-field"
><span>Units to Sell *</span><input type="number" [(ngModel)]="sellUnits" min="0"
/></label>
<label class="inv-form-field"
><span>Sell Rate *</span><input type="number" [(ngModel)]="sellRate" min="0" step="0.01"
/></label>
<label class="inv-form-field"
><span>Consideration (computed)</span
><input type="text" [value]="'₹' + (sellConsideration | number: '1.2-2')" readonly
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="saveSell()">Save</button>
</div>
</div>
}
<!-- WRITE OFF ENTRY -->
@if (activeTab === 'writeoff') {
<div class="pg-card">
<div class="pos-section-label">Investment - Share Write Off Entry</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Cert No *</span>
<select [(ngModel)]="writeoffCertNo">
<option value="">-- Select --</option>
@for (h of activeHoldings; track h.certNo) {
<option [value]="h.certNo">{{ h.certNo }} — {{ h.scriptName }}</option>
}
</select>
</label>
@if (writeoffHolding; as h) {
<label class="inv-form-field"
><span>Face Value to be Written Off</span
><input type="text" [value]="'₹' + (h.faceValue | number: '1.2-2')" readonly
/></label>
}
<label class="inv-form-field"><span>Reason *</span><input type="text" [(ngModel)]="writeoffReason" /></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="saveWriteoff()">Save</button>
</div>
</div>
}
@if (activeTab !== 'holdings') {
<div class="pg-card">
<div class="pos-section-label">Share Transaction Log</div>
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>Voucher No</th>
<th>Cert No</th>
<th>Type</th>
<th>Particulars</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (t of txns; track t.voucherNo) {
<tr>
<td>{{ t.txnDate }}</td>
<td>
<span class="pg-chip">{{ t.voucherNo }}</span>
</td>
<td>{{ t.certNo }}</td>
<td>{{ t.txnType }}</td>
<td>{{ t.particulars }}</td>
<td class="num">₹{{ t.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
@@ -0,0 +1,263 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { SHARE_HOLDINGS, ALL_TXNS, InvestmentHolding, InvestmentTxn, todayDDMMYYYY } from '../investment-data';
type ShareTab = 'holdings' | 'purchase' | 'dividend' | 'sell' | 'writeoff';
interface SharePurchaseForm {
certNo: string;
scriptCode: string;
scriptName: string;
party: string;
subType: string;
units: number | null;
purchaseRate: number | null;
purchaseDate: string;
brokerCode: string;
crToAc: string;
}
/**
* Share Entry treasury screen — Holdings register + Purchase / Dividend / Sell / Write-off
* entry tabs (legacy "02 Share Entry" menu). Dummy in-memory data; Save appends locally.
*/
@Component({
selector: 'mifosx-share-investment',
templateUrl: './share-investment.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../investment-shared.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule
]
})
export class ShareInvestmentComponent {
activeTab: ShareTab = 'holdings';
asOnDate = todayDDMMYYYY();
holdings: InvestmentHolding[] = [...SHARE_HOLDINGS];
txns: InvestmentTxn[] = ALL_TXNS.filter((t) => t.instrumentType === 'SHARE');
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
purchaseForm: SharePurchaseForm = this.blankPurchaseForm();
dividendCertNo = '';
dividendPerShare: number | null = null;
sellCertNo = '';
sellUnits: number | null = null;
sellRate: number | null = null;
writeoffCertNo = '';
writeoffReason = '';
setTab(tab: ShareTab): void {
this.activeTab = tab;
}
get activeHoldings(): InvestmentHolding[] {
return this.holdings.filter((h) => h.status === 'ACTIVE');
}
private blankPurchaseForm(): SharePurchaseForm {
return {
certNo: `SH-${6000 + this.holdings.length + 1}`,
scriptCode: '',
scriptName: '',
party: 'NSE/BSE',
subType: 'EQUITY',
units: null,
purchaseRate: null,
purchaseDate: this.asOnDate,
brokerCode: '',
crToAc: '310070'
};
}
savePurchase(): void {
const f = this.purchaseForm;
if (!f.scriptName || !f.units || !f.purchaseRate) {
this.toast('Please fill Script Name, No. of Units and Purchase Rate.');
return;
}
const faceValue = f.units * f.purchaseRate;
const holding: InvestmentHolding = {
certNo: f.certNo,
instrumentType: 'SHARE',
scriptCode: f.scriptCode || f.scriptName,
scriptName: f.scriptName,
party: f.party,
subType: f.subType,
faceValue,
purchaseRate: f.purchaseRate,
units: f.units,
purchaseDate: f.purchaseDate,
maturityDate: f.purchaseDate,
interestRate: 0,
interestFreq: 'YEARLY',
glCode: '310060',
glName: 'INVESTMENT IN SHARES',
crToAc: f.crToAc,
brokerCode: f.brokerCode,
status: 'ACTIVE'
};
this.holdings = [
holding,
...this.holdings
];
this.txns = [
{
certNo: holding.certNo,
instrumentType: 'SHARE',
txnType: 'PURCHASE',
voucherNo: `INV/PUR/${holding.certNo}`,
txnDate: f.purchaseDate,
amount: faceValue,
particulars: `Purchase of ${f.units} shares - ${holding.scriptName}`
},
...this.txns
];
this.toast(`Share holding ${holding.certNo} saved successfully.`);
this.purchaseForm = this.blankPurchaseForm();
this.activeTab = 'holdings';
}
cancelPurchase(): void {
this.purchaseForm = this.blankPurchaseForm();
}
// -------------------- Dividend --------------------
get dividendHolding(): InvestmentHolding | undefined {
return this.holdings.find((h) => h.certNo === this.dividendCertNo);
}
get dividendAmount(): number {
const h = this.dividendHolding;
if (!h || !this.dividendPerShare) {
return 0;
}
return Math.round(h.units * this.dividendPerShare);
}
saveDividend(): void {
const h = this.dividendHolding;
if (!h || !this.dividendPerShare) {
this.toast('Select a holding and enter dividend per share.');
return;
}
const amt = this.dividendAmount;
this.txns = [
{
certNo: h.certNo,
instrumentType: 'SHARE',
txnType: 'DIVIDEND',
voucherNo: `INV/DIV/${h.certNo}-${this.txns.length + 1}`,
txnDate: this.asOnDate,
amount: amt,
particulars: `Dividend received - ${h.scriptName} (₹${this.dividendPerShare}/share)`
},
...this.txns
];
this.toast(`Dividend of ₹${amt.toLocaleString('en-IN')} recorded for ${h.certNo}.`);
this.dividendCertNo = '';
this.dividendPerShare = null;
}
// -------------------- Sell --------------------
get sellHolding(): InvestmentHolding | undefined {
return this.holdings.find((h) => h.certNo === this.sellCertNo);
}
get sellConsideration(): number {
if (!this.sellUnits || !this.sellRate) {
return 0;
}
return Math.round(this.sellUnits * this.sellRate);
}
saveSell(): void {
const h = this.sellHolding;
if (!h || !this.sellUnits || !this.sellRate || this.sellUnits > h.units) {
this.toast('Select a holding and enter valid units (≤ held) and sell rate.');
return;
}
const consideration = this.sellConsideration;
h.units -= this.sellUnits;
h.faceValue = Math.round(h.units * h.purchaseRate);
if (h.units === 0) {
h.status = 'SOLD';
}
this.txns = [
{
certNo: h.certNo,
instrumentType: 'SHARE',
txnType: 'SELL',
voucherNo: `INV/SEL/${h.certNo}-${this.txns.length + 1}`,
txnDate: this.asOnDate,
amount: consideration,
particulars: `Sold ${this.sellUnits} shares of ${h.scriptName} @ ${this.sellRate}`
},
...this.txns
];
this.toast(`Sold ${this.sellUnits} shares. Consideration ₹${consideration.toLocaleString('en-IN')}.`);
this.sellCertNo = '';
this.sellUnits = null;
this.sellRate = null;
this.activeTab = 'holdings';
}
// -------------------- Write-off --------------------
get writeoffHolding(): InvestmentHolding | undefined {
return this.holdings.find((h) => h.certNo === this.writeoffCertNo);
}
saveWriteoff(): void {
const h = this.writeoffHolding;
if (!h || !this.writeoffReason) {
this.toast('Select a holding and enter a write-off reason.');
return;
}
h.status = 'WRITTEN_OFF';
this.txns = [
{
certNo: h.certNo,
instrumentType: 'SHARE',
txnType: 'WRITE_OFF',
voucherNo: `INV/WOF/${h.certNo}`,
txnDate: this.asOnDate,
amount: h.faceValue,
particulars: `Written off - ${this.writeoffReason}`
},
...this.txns
];
this.toast(`${h.certNo} written off.`);
this.writeoffCertNo = '';
this.writeoffReason = '';
this.activeTab = 'holdings';
}
statusClass(h: InvestmentHolding): string {
return `inv-badge inv-badge--${h.status.toLowerCase()}`;
}
private toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
if (this.toastTimer) {
clearTimeout(this.toastTimer);
}
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
}
}
+619
View File
@@ -0,0 +1,619 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
// Dummy NPA (Non-Performing Asset) data layer for the NPA Reports / Analysis / Classification
// screens. Everything downstream — classification, provisioning, interest reversal, GNPA/NNPA —
// is *derived* from LOAN_ACCOUNTS via pure functions below, so no two reports can ever disagree
// on the same account's numbers.
//
// The book is generated with a seeded RNG (not Math.random()) so the "large, real-world-CBS"
// dataset stays identical across reloads instead of reshuffling every time the page opens.
export type NpaStage =
| 'STANDARD'
| 'SMA0'
| 'SMA1'
| 'SMA2'
| 'SUBSTANDARD'
| 'DOUBTFUL1'
| 'DOUBTFUL2'
| 'DOUBTFUL3'
| 'LOSS';
export const STAGE_ORDER: NpaStage[] = [
'STANDARD',
'SMA0',
'SMA1',
'SMA2',
'SUBSTANDARD',
'DOUBTFUL1',
'DOUBTFUL2',
'DOUBTFUL3',
'LOSS'
];
export const STAGE_LABELS: Record<NpaStage, string> = {
STANDARD: 'Standard',
SMA0: 'SMA-0',
SMA1: 'SMA-1',
SMA2: 'SMA-2',
SUBSTANDARD: 'Sub-Standard',
DOUBTFUL1: 'Doubtful-1',
DOUBTFUL2: 'Doubtful-2',
DOUBTFUL3: 'Doubtful-3',
LOSS: 'Loss Asset'
};
export function isNpaStage(stage: NpaStage): boolean {
return stage !== 'STANDARD' && stage !== 'SMA0' && stage !== 'SMA1' && stage !== 'SMA2';
}
export function isSmaStage(stage: NpaStage): boolean {
return stage === 'SMA0' || stage === 'SMA1' || stage === 'SMA2';
}
export function stageBadgeClass(stage: NpaStage): string {
return `npa-badge npa-badge--${stage.toLowerCase()}`;
}
/** Recovery-workflow stage tracked separately from IRAC classification — a Doubtful/Loss
* account can sit at any of these while its classification stays driven purely by overdueDays. */
export type LegalActionStatus = 'NONE' | 'NOTICE_ISSUED' | 'SARFAESI_INITIATED' | 'SUIT_FILED' | 'OTS_NEGOTIATION';
export const LEGAL_ACTION_LABELS: Record<LegalActionStatus, string> = {
NONE: 'No Action Yet',
NOTICE_ISSUED: 'Demand Notice Issued',
SARFAESI_INITIATED: 'SARFAESI Action Initiated',
SUIT_FILED: 'Recovery Suit Filed',
OTS_NEGOTIATION: 'One-Time Settlement Under Negotiation'
};
export interface RecoveryTxn {
date: string;
amount: number;
mode: string;
}
export interface LoanAccount {
accountNo: string;
customerName: string;
guarantorName: string;
branchCode: string;
branchName: string;
productCode: string;
productName: string;
secured: boolean;
sanctionedAmount: number;
disbursementDate: string;
interestRate: number;
outstandingBalance: number;
overdueAmount: number;
overdueDays: number;
overdueDaysQuarterAgo: number;
collateralValue: number;
lastPaymentDate: string;
isLossAsset: boolean;
isRestructured: boolean;
provisionCoverageRatio: number;
recoveries: RecoveryTxn[];
/** '—' for Standard/SMA accounts — only NPA accounts are actually assigned a reason. */
npaReason: string;
/** '—' for Standard accounts — SMA and NPA accounts get a recovery officer assigned for follow-up. */
recoveryOfficer: string;
legalActionStatus: LegalActionStatus;
}
// -------------------------------------------------------------------------
// Date helpers
// -------------------------------------------------------------------------
function fmt(d: Date): string {
return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
}
function addDays(d: Date, days: number): Date {
const r = new Date(d);
r.setDate(r.getDate() + days);
return r;
}
/** Parses a "DD-MM-YYYY" string back into a Date, so generated dates can be compared/ordered
* against each other (e.g. "is this recovery more recent than the last-payment estimate?"). */
function parseDMY(s: string): Date {
const [
d,
m,
y
] = s.split('-').map(Number);
return new Date(y, m - 1, d);
}
export function todayDDMMYYYY(): string {
return fmt(new Date());
}
// -------------------------------------------------------------------------
// Seeded RNG — keeps the generated book stable across reloads
// -------------------------------------------------------------------------
function mulberry32(seed: number): () => number {
return function random(): number {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rng = mulberry32(19870614);
const pick = <T>(arr: T[]): T => arr[Math.floor(rng() * arr.length)];
const between = (min: number, max: number): number => min + rng() * (max - min);
const round = (n: number): number => Math.round(n);
// -------------------------------------------------------------------------
// Masters
// -------------------------------------------------------------------------
export interface BranchMaster {
code: string;
name: string;
}
// BR01/BR02 are deliberately named "Branch 1"/"Branch 2" — identical to the two branches in
// cash-clearing-transfer-summary.component.ts — so a single pair of real office names in the DB
// gets recognised by BOTH reports instead of each module expecting a different branch name.
export const BRANCHES: BranchMaster[] = [
{ code: 'BR01', name: 'Branch 1' },
{ code: 'BR02', name: 'Branch 2' },
{ code: 'BR03', name: 'Borivali Branch' },
{ code: 'BR04', name: 'Thane Branch' },
{ code: 'BR05', name: 'Pune Branch' },
{ code: 'BR06', name: 'Nashik Branch' },
{ code: 'BR07', name: 'Nagpur Branch' },
{ code: 'BR08', name: 'Kolhapur Branch' }
];
interface ProductMaster {
code: string;
name: string;
secured: boolean;
minAmt: number;
maxAmt: number;
minRate: number;
maxRate: number;
}
export const LOAN_PRODUCTS: ProductMaster[] = [
{
code: '01',
name: 'Cash Credit (MSME)',
secured: true,
minAmt: 500000,
maxAmt: 5000000,
minRate: 11,
maxRate: 13.5
},
{ 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',
name: 'SHG / Micro Finance Loan',
secured: false,
minAmt: 20000,
maxAmt: 200000,
minRate: 12,
maxRate: 14
},
{
code: '10',
name: 'Overdraft Against Property',
secured: true,
minAmt: 500000,
maxAmt: 2500000,
minRate: 11,
maxRate: 13
}
];
const FIRST_NAMES = [
'Ramesh',
'Suresh',
'Mahesh',
'Ganesh',
'Dinesh',
'Vijay',
'Sanjay',
'Ajay',
'Prakash',
'Vinod',
'Sunita',
'Anita',
'Kavita',
'Savita',
'Rekha',
'Meena',
'Pooja',
'Sangita',
'Nirmala',
'Usha',
'Ravindra',
'Mahendra',
'Narendra',
'Jitendra',
'Rajendra',
'Bhagwan',
'Balasaheb',
'Vasant',
'Shivaji',
'Yogesh',
'Kiran',
'Manisha',
'Deepak',
'Sandeep',
'Pradeep',
'Ashok',
'Vishal',
'Nitin',
'Amol',
'Santosh'
];
const LAST_NAMES = [
'Patil',
'More',
'Jadhav',
'Deshmukh',
'Pawar',
'Kulkarni',
'Shinde',
'Gaikwad',
'Chavan',
'Bhosale',
'Kale',
'Salunkhe',
'Sawant',
'Kadam',
'Joshi',
'Wagh',
'Thorat',
'Waghmare',
'Mane',
'Shaikh'
];
const NPA_REASONS = [
'Crop Failure / Agri Distress',
'Business Loss',
'Health Issue / Death of Borrower',
'Diversion of Funds',
'Wilful Default',
'Natural Calamity',
'Loss of Employment',
'Family Dispute / Litigation',
'Delayed Receivables from Debtors',
'Unit Closure'
];
/** Recovery officers are bank staff, kept distinct from the FIRST_NAMES/LAST_NAMES pool used for
* customers so a name never accidentally reads as "the officer is also the borrower". */
const RECOVERY_OFFICERS = [
'Anil Deshpande',
'Sunil Kadam',
'Vikas Pathak',
'Rajendra More',
'Sachin Bhagat',
'Prashant Jagtap',
'Milind Kulkarni',
'Ravi Ghadge'
];
// -------------------------------------------------------------------------
// Weighted stage buckets — used only to seed a realistic distribution of
// overdue-day counts; the actual classification is always re-derived from
// `overdueDays` via classifyDays(), never stored directly.
// -------------------------------------------------------------------------
interface StageBucket {
weight: number;
minDays: number;
maxDays: number;
isLossPool: boolean;
}
const BUCKETS: StageBucket[] = [
{ weight: 60, minDays: 0, maxDays: 0, isLossPool: false },
{ weight: 10, minDays: 1, maxDays: 30, isLossPool: false },
{ weight: 7, minDays: 31, maxDays: 60, isLossPool: false },
{ weight: 6, minDays: 61, maxDays: 90, isLossPool: false },
{ weight: 9, minDays: 91, maxDays: 365, isLossPool: false },
{ weight: 4, minDays: 366, maxDays: 730, isLossPool: false },
{ weight: 2.5, minDays: 731, maxDays: 1095, isLossPool: false },
{ weight: 1.5, minDays: 1096, maxDays: 1900, isLossPool: true }
];
const TOTAL_WEIGHT = BUCKETS.reduce((s, b) => s + b.weight, 0);
function pickBucket(): StageBucket {
let r = rng() * TOTAL_WEIGHT;
for (const b of BUCKETS) {
if (r < b.weight) {
return b;
}
r -= b.weight;
}
return BUCKETS[0];
}
/** Recovery-workflow progression — only NPA accounts get any legal action, and the more
* advanced/stressed the classification, the more likely the bank has already escalated. */
function legalActionFor(stage: NpaStage): LegalActionStatus {
if (!isNpaStage(stage)) {
return 'NONE';
}
const r = rng();
if (stage === 'SUBSTANDARD') {
return r < 0.2 ? 'NOTICE_ISSUED' : 'NONE';
}
if (stage === 'DOUBTFUL1') {
if (r < 0.15) return 'OTS_NEGOTIATION';
if (r < 0.45) return 'SARFAESI_INITIATED';
if (r < 0.65) return 'NOTICE_ISSUED';
return 'NONE';
}
// DOUBTFUL2, DOUBTFUL3, LOSS
if (r < 0.1) return 'OTS_NEGOTIATION';
if (r < 0.55) return 'SUIT_FILED';
if (r < 0.8) return 'SARFAESI_INITIATED';
if (r < 0.9) return 'NOTICE_ISSUED';
return 'NONE';
}
function generateAccounts(count: number): LoanAccount[] {
const today = new Date();
const accounts: LoanAccount[] = [];
for (let i = 0; i < count; i++) {
const branch = pick(BRANCHES);
const product = pick(LOAN_PRODUCTS);
const bucket = pickBucket();
const overdueDays = bucket.maxDays === 0 ? 0 : round(between(bucket.minDays, bucket.maxDays));
const isLossAsset = bucket.isLossPool && rng() < 0.4;
// Restructured accounts skew towards the already-stressed buckets (SMA-2 and worse),
// matching how RBI restructuring relief is actually used in practice.
const stressed = classifyDays(overdueDays, isLossAsset);
const isRestructured =
rng() < (stressed === 'STANDARD' || stressed === 'SMA0' || stressed === 'SMA1' ? 0.04 : 0.18);
const sanctionedAmount = round(between(product.minAmt, product.maxAmt) / 1000) * 1000;
// The loan must be old enough to have actually accrued `overdueDays` of delinquency, plus a
// minimum "good" performing stretch beforehand — otherwise a badly-overdue account could end
// up with a disbursement date that falls AFTER its own last-payment/NPA date, which would look
// wrong no matter what day this report is viewed on.
const minGoodPeriodDays = round(between(30, 180));
const loanAgeDays = Math.max(round(between(0.5, 8) * 365), overdueDays + minGoodPeriodDays);
const disbursementDate = fmt(addDays(today, -loanAgeDays));
const interestRate = +between(product.minRate, product.maxRate).toFixed(2);
const outstandingBalance = round(sanctionedAmount * between(0.25, 0.9));
const overdueFrac = overdueDays === 0 ? 0 : Math.min(0.85, overdueDays / 1200 + between(0, 0.15));
const overdueAmount = round(outstandingBalance * overdueFrac);
const collateralValue = product.secured ? round(sanctionedAmount * between(0.85, 1.25)) : 0;
const npaReason = isNpaStage(stressed) ? pick(NPA_REASONS) : '—';
const recoveryOfficer = isNpaStage(stressed) || isSmaStage(stressed) ? pick(RECOVERY_OFFICERS) : '—';
const legalActionStatus = legalActionFor(stressed);
// Quarter-ago overdue count is capped at how old the loan actually was 90 days back — a loan
// can't have been overdue for longer than it had existed at that point in time.
const drift = round((rng() - 0.4) * 130);
const maxQuarterAgoOverdueDays = Math.max(0, loanAgeDays - 90);
const overdueDaysQuarterAgo = Math.min(Math.max(0, overdueDays - drift), maxQuarterAgoOverdueDays);
const recoveries: RecoveryTxn[] = [];
if (overdueDays > 90 && rng() < 0.5) {
const numRecoveries = 1 + Math.floor(rng() * 3);
for (let r = 0; r < numRecoveries; r++) {
recoveries.push({
date: fmt(addDays(today, -round(between(10, Math.min(overdueDays, 400))))),
amount: round(outstandingBalance * between(0.02, 0.12)),
mode: pick([
'Cash Deposit',
'Cheque',
'NEFT/RTGS',
'Auction / SARFAESI',
'One-Time Settlement'
])
});
}
}
// A recorded recovery IS a payment — if one landed more recently than the formula-based
// last-payment estimate, the account's "last payment" must reflect that, otherwise the two
// fields on the Account Detail screen would visibly contradict each other.
const formulaLastPaymentDate = fmt(addDays(today, -Math.max(5, overdueDays - round(between(0, 20)))));
const latestRecoveryDate = recoveries.reduce<Date | null>((latest, r) => {
const d = parseDMY(r.date);
return !latest || d > latest ? d : latest;
}, null);
const lastPaymentDate =
latestRecoveryDate && latestRecoveryDate > parseDMY(formulaLastPaymentDate)
? fmt(latestRecoveryDate)
: formulaLastPaymentDate;
const seq = String(1000 + i).padStart(6, '0');
accounts.push({
accountNo: `1${branch.code.slice(2)}${product.code}${seq}`,
customerName: `${pick(FIRST_NAMES)} ${pick(LAST_NAMES)}`,
guarantorName: `${pick(FIRST_NAMES)} ${pick(LAST_NAMES)}`,
branchCode: branch.code,
branchName: branch.name,
productCode: product.code,
productName: product.name,
secured: product.secured,
sanctionedAmount,
disbursementDate,
interestRate,
outstandingBalance,
overdueAmount,
overdueDays,
overdueDaysQuarterAgo,
collateralValue,
lastPaymentDate,
isLossAsset,
isRestructured,
provisionCoverageRatio: +between(0.75, 1.1).toFixed(2),
recoveries,
npaReason,
recoveryOfficer,
legalActionStatus
});
}
return accounts;
}
// 8 branches x ~80 accounts/branch on average — sized to look like a real cooperative bank's
// loan book rather than a token demo sample. Pagination in the reports UI is what makes a book
// this size usable, not a smaller dataset.
export const LOAN_ACCOUNTS: LoanAccount[] = generateAccounts(640);
// -------------------------------------------------------------------------
// Classification & provisioning (RBI IRAC norms, simplified for a demo book)
// -------------------------------------------------------------------------
export function classifyDays(days: number, isLossAsset: boolean): NpaStage {
if (isLossAsset && days > 1095) {
return 'LOSS';
}
if (days <= 0) {
return 'STANDARD';
}
if (days <= 30) {
return 'SMA0';
}
if (days <= 60) {
return 'SMA1';
}
if (days <= 90) {
return 'SMA2';
}
if (days <= 365) {
return 'SUBSTANDARD';
}
if (days <= 730) {
return 'DOUBTFUL1';
}
if (days <= 1095) {
return 'DOUBTFUL2';
}
return 'DOUBTFUL3';
}
export function currentStage(a: LoanAccount): NpaStage {
return classifyDays(a.overdueDays, a.isLossAsset);
}
export function previousStage(a: LoanAccount): NpaStage {
return classifyDays(a.overdueDaysQuarterAgo, a.isLossAsset && a.overdueDaysQuarterAgo > 1095);
}
export function provisionRatePct(stage: NpaStage, secured: boolean): number {
switch (stage) {
case 'STANDARD':
case 'SMA0':
case 'SMA1':
return 0.4;
case 'SMA2':
return 0.75;
case 'SUBSTANDARD':
return secured ? 15 : 25;
case 'DOUBTFUL1':
return secured ? 25 : 100;
case 'DOUBTFUL2':
return secured ? 40 : 100;
case 'DOUBTFUL3':
case 'LOSS':
return 100;
}
}
export function provisionRequired(a: LoanAccount): number {
const stage = currentStage(a);
if (!isNpaStage(stage)) {
return round(a.outstandingBalance * (provisionRatePct(stage, a.secured) / 100));
}
const securedPortion = Math.min(a.outstandingBalance, a.collateralValue);
const unsecuredPortion = Math.max(0, a.outstandingBalance - a.collateralValue);
const securedRate = provisionRatePct(stage, true) / 100;
const unsecuredRate = provisionRatePct(stage, false) / 100;
return round(securedPortion * securedRate + unsecuredPortion * unsecuredRate);
}
/** Provision held can run a little above or below what's required (over/under-provisioning
* happens in real banks) — but it can never exceed the account's own outstanding balance, since
* a bank cannot hold more provision than the money actually at risk on that one loan. */
export function provisionHeld(a: LoanAccount): number {
return Math.min(a.outstandingBalance, round(provisionRequired(a) * a.provisionCoverageRatio));
}
/** Interest income de-recognised because the account has been NPA for longer than 90 days. */
export function interestReversed(a: LoanAccount): number {
const stage = currentStage(a);
if (!isNpaStage(stage)) {
return 0;
}
const daysSinceNpa = Math.max(0, a.overdueDays - 90);
return round(a.outstandingBalance * (a.interestRate / 100) * (daysSinceNpa / 365));
}
/** Date the account first crossed 90 days overdue (i.e. became NPA) — derived from overdueDays,
* never stored separately, so it always agrees with the account's current classification. */
export function npaSinceDate(a: LoanAccount): string {
if (!isNpaStage(currentStage(a))) {
return '—';
}
const daysSinceNpa = Math.max(0, a.overdueDays - 90);
return fmt(addDays(new Date(), -daysSinceNpa));
}
export function totalRecovered(a: LoanAccount): number {
return a.recoveries.reduce((s, r) => s + r.amount, 0);
}
export function movementLabel(prev: NpaStage, curr: NpaStage): string {
if (prev === curr) {
return 'No Change';
}
const prevIdx = STAGE_ORDER.indexOf(prev);
const currIdx = STAGE_ORDER.indexOf(curr);
if (currIdx > prevIdx) {
return !isNpaStage(prev) && isNpaStage(curr) ? 'Fresh Slippage to NPA' : 'Downgraded';
}
return isNpaStage(prev) && !isNpaStage(curr) ? 'Upgraded to Standard' : 'Improved';
}
// -------------------------------------------------------------------------
// Report menu metadata — drives both the shell mega-menu routerLinks and the
// in-page group/report tab rows, so a menu item and its screen can never
// point at different labels.
// -------------------------------------------------------------------------
export type ReportGroupId = 'reports' | 'analysis' | 'classification';
export interface ReportMeta {
id: string;
group: ReportGroupId;
label: string;
}
export const REPORT_META: ReportMeta[] = [
{ id: 'interest', group: 'reports', label: 'NPA Interest' },
{ id: 'status', group: 'reports', label: 'NPA Status Report' },
{ id: 'recovery', group: 'reports', label: 'NPA Recovery Report' },
{ id: 'summary', group: 'reports', label: 'NPA Summary Report' },
{ id: 'account-detail', group: 'analysis', label: 'NPA Account Detail Report' },
{ id: 'movement', group: 'analysis', label: 'NPA Movement Report' },
{ id: 'provision', group: 'analysis', label: 'NPA with Provision and Required Provision' },
{ id: 'probable', group: 'classification', label: 'Probable NPA' },
{ id: 'sma', group: 'classification', label: 'SMA Report' }
];
export const GROUP_LABELS: Record<ReportGroupId, string> = {
reports: 'NPA Reports',
analysis: 'NPA Analysis',
classification: 'NPA Classification'
};
export const DEFAULT_REPORT_ID = 'summary';
@@ -0,0 +1,949 @@
<!--
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 · NON-PERFORMING ASSETS · {{ scopeName | uppercase }}</div>
<h1 class="pg-title">{{ reportLabel(activeReport) }}</h1>
<p class="pg-sub">
RBI IRAC-norm classification (Standard → SMA-0/1/2 → Sub-Standard → Doubtful-1/2/3 → Loss) across
{{ allAccounts.length }} of the bank's {{ bankWideTotalAccounts }} loan accounts, 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>
}
<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>
</div>
</header>
<div class="inv-kpis npa-kpis--ratio">
<div class="inv-kpi">
<span class="inv-kpi__label">Total Advances</span>
<span class="inv-kpi__value">₹{{ totalAdvances | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">{{ allAccounts.length }} accounts</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Gross NPA (GNPA)</span>
<span class="inv-kpi__value npa-warn">₹{{ gnpaAmount | number: '1.0-0' }}</span>
<span class="inv-kpi__sub"
>{{ npaAccounts.length }} accounts · {{ gnpaRatio | number: '1.2-2' }}% of advances</span
>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Net NPA (NNPA)</span>
<span class="inv-kpi__value npa-warn">₹{{ netNpaAmount | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">{{ nnpaRatio | number: '1.2-2' }}% of advances</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Provision Held (NPA)</span>
<span class="inv-kpi__value">₹{{ totalNpaProvisionHeld | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">Recovered to date ₹{{ totalRecoveredAmount | number: '1.0-0' }}</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Restructured Accounts</span>
<span class="inv-kpi__value">{{ restructuredAccounts.length }}</span>
<span class="inv-kpi__sub">₹{{ restructuredOutstanding | number: '1.0-0' }} outstanding</span>
</div>
</div>
<div class="cctr-tabs">
@for (g of ['reports', 'analysis', 'classification']; 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>
<!-- ===================== 01 NPA INTEREST ===================== -->
@if (activeReport === 'interest') {
<div class="pg-card">
<div class="pos-section-label">NPA Interest — Income Reversed on Non-Performing Accounts</div>
<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">Rate</th>
<th class="num">Overdue Days</th>
<th class="num col-out">Interest Reversed</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>{{ 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.interestRate }}%</td>
<td class="num">{{ a.overdueDays }}</td>
<td class="num col-out">₹{{ interestRev(a) | number: '1.0-0' }}</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="pg-empty">No NPA accounts in this branch.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="8">Total Interest Reversed ({{ npaAccounts.length }} accounts)</td>
<td class="num">₹{{ sumInterest(npaAccounts) | number: '1.0-0' }}</td>
</tr>
</tfoot>
</table>
@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>
}
<!-- ===================== 02 NPA STATUS REPORT ===================== -->
@if (activeReport === 'status') {
<div class="pg-card">
<div class="pos-section-label">NPA Status Report — Full Non-Performing Book</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>Secured</th>
<th class="num">Outstanding</th>
<th class="num">Overdue Amount</th>
<th class="num">Overdue Days</th>
<th>NPA Since</th>
<th>Stage</th>
<th>Restructured</th>
<th>Legal Action</th>
<th class="num col-out">Provision Required</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>{{ a.productName }}</td>
<td>{{ a.secured ? 'Yes' : 'No' }}</td>
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
<td class="num">₹{{ a.overdueAmount | number: '1.0-0' }}</td>
<td class="num">{{ a.overdueDays }}</td>
<td>{{ npaSince(a) }}</td>
<td>
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
</td>
<td>{{ a.isRestructured ? 'Yes' : 'No' }}</td>
<td [class]="legalActionClass(a.legalActionStatus)">{{ legalActionLabel(a.legalActionStatus) }}</td>
<td class="num col-out">₹{{ provisionReq(a) | number: '1.0-0' }}</td>
</tr>
} @empty {
<tr>
<td colspan="13" class="pg-empty">No NPA accounts in this branch.</td>
</tr>
}
</tbody>
</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>
}
<!-- ===================== 03 NPA RECOVERY REPORT ===================== -->
@if (activeReport === 'recovery') {
<div class="pg-card">
<div class="pos-section-label">Record a Recovery Against an NPA Account</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>* NPA Account</span>
<select [(ngModel)]="newRecovery.accountNo">
<option value="">-- Select --</option>
@for (a of recoveryOptions; track a.accountNo) {
<option [value]="a.accountNo">
{{ a.accountNo }} — {{ a.customerName }} ({{ stageLabel(stage(a)) }})
</option>
}
</select>
</label>
<label class="inv-form-field">
<span>* Recovery Date</span>
<input type="text" [(ngModel)]="newRecovery.date" placeholder="DD-MM-YYYY" />
</label>
<label class="inv-form-field">
<span>* Recovery Amount</span>
<input type="number" [(ngModel)]="newRecovery.amount" />
</label>
<label class="inv-form-field">
<span>Mode</span>
<select [(ngModel)]="newRecovery.mode">
<option>Cash Deposit</option>
<option>Cheque</option>
<option>NEFT/RTGS</option>
<option>Auction / SARFAESI</option>
<option>One-Time Settlement</option>
</select>
</label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="addRecovery()">Save Recovery Entry</button>
</div>
</div>
<div class="pg-card">
<div class="pos-section-label">
NPA Recovery Register — ₹{{ totalRecoveredAmount | number: '1.0-0' }} recovered against ₹{{
gnpaAmount | number: '1.0-0'
}}
GNPA outstanding
</div>
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Mode</th>
<th class="num col-in">Recovered Amount</th>
</tr>
</thead>
<tbody>
@for (r of paged(recoveryRows); track r.accountNo + r.date + r.amount) {
<tr>
<td>{{ r.date }}</td>
<td>
<span class="pg-chip">{{ r.accountNo }}</span>
</td>
<td>{{ r.customerName }}</td>
<td>{{ r.branchName }}</td>
<td>{{ r.mode }}</td>
<td class="num col-in">₹{{ r.amount | number: '1.0-0' }}</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="pg-empty">No recoveries recorded yet.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="5">Total Recovered</td>
<td class="num">₹{{ totalRecoveredAmount | number: '1.0-0' }}</td>
</tr>
</tfoot>
</table>
@if (recoveryRows.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(recoveryRows) }}–{{ rangeEnd(recoveryRows) }} of {{ recoveryRows.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, recoveryRows)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(recoveryRows) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(recoveryRows)"
(click)="goToPage(currentPage + 1, recoveryRows)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== 04 NPA SUMMARY REPORT ===================== -->
@if (activeReport === 'summary') {
<div class="pg-card">
<div class="pos-section-label">Stage-wise Summary — {{ scopeName }}</div>
<table class="pg-table">
<thead>
<tr>
<th>Classification</th>
<th class="num">Accounts</th>
<th class="num">Outstanding</th>
<th class="num col-out">Provision Required</th>
</tr>
</thead>
<tbody>
@for (row of stageSummary; track row.stage) {
<tr>
<td>
<span [class]="badgeClass(row.stage)">{{ row.label }}</span>
</td>
<td class="num">{{ row.count }}</td>
<td class="num">₹{{ row.outstanding | number: '1.0-0' }}</td>
<td class="num col-out">₹{{ row.provisionReq | number: '1.0-0' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td>Total</td>
<td class="num">{{ grandTotalAccounts }}</td>
<td class="num">₹{{ grandTotalOutstanding | number: '1.0-0' }}</td>
<td class="num">₹{{ grandTotalProvision | number: '1.0-0' }}</td>
</tr>
</tfoot>
</table>
</div>
@if (isHeadOffice && !branchFilter) {
<div class="pg-card">
<div class="pos-section-label">Branch-wise NPA Summary — All Branches</div>
<table class="pg-table">
<thead>
<tr>
<th>Branch</th>
<th class="num">Total Accounts</th>
<th class="num">Total Advances</th>
<th class="num">NPA Accounts</th>
<th class="num col-out">GNPA Outstanding</th>
<th class="num col-out">Provision Required</th>
<th class="num">GNPA %</th>
</tr>
</thead>
<tbody>
@for (row of branchSummary; track row.branchCode) {
<tr>
<td>
<a href="javascript:void(0)" (click)="setBranch(row.branchCode)">{{ row.branchName }}</a>
</td>
<td class="num">{{ row.totalAccounts }}</td>
<td class="num">₹{{ row.totalAdvances | number: '1.0-0' }}</td>
<td class="num">{{ row.npaCount }}</td>
<td class="num col-out">₹{{ row.npaOutstanding | number: '1.0-0' }}</td>
<td class="num col-out">₹{{ row.provisionReq | number: '1.0-0' }}</td>
<td class="num">{{ row.gnpaRatio | number: '1.2-2' }}%</td>
</tr>
}
</tbody>
</table>
</div>
}
}
<!-- ===================== 05 NPA ACCOUNT DETAIL REPORT ===================== -->
@if (activeReport === 'account-detail') {
<div class="pg-card">
<div class="pos-section-label">Search NPA Account</div>
<div class="inv-form-grid">
<label class="inv-form-field npa-form-field--full">
<span>Account No / Customer</span>
<select [(ngModel)]="selectedAccountNo">
@for (a of npaAccounts; track a.accountNo) {
<option [value]="a.accountNo">{{ a.accountNo }} — {{ a.customerName }} ({{ a.branchName }})</option>
}
</select>
</label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="searchAccount()">View Details</button>
</div>
</div>
@if (selectedAccount; as a) {
<div class="pg-card">
<div class="pos-section-label">
{{ a.accountNo }} — {{ a.customerName }}
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
</div>
<dl class="npa-detail-grid">
<div class="npa-detail-field">
<dt>Branch</dt>
<dd>{{ a.branchName }}</dd>
</div>
<div class="npa-detail-field">
<dt>Product</dt>
<dd>{{ a.productName }}</dd>
</div>
<div class="npa-detail-field">
<dt>Secured</dt>
<dd>{{ a.secured ? 'Yes' : 'No' }}</dd>
</div>
<div class="npa-detail-field">
<dt>Guarantor</dt>
<dd>{{ a.guarantorName }}</dd>
</div>
<div class="npa-detail-field">
<dt>Sanctioned Amount</dt>
<dd>₹{{ a.sanctionedAmount | number: '1.0-0' }}</dd>
</div>
<div class="npa-detail-field">
<dt>Disbursement Date</dt>
<dd>{{ a.disbursementDate }}</dd>
</div>
<div class="npa-detail-field">
<dt>Interest Rate</dt>
<dd>{{ a.interestRate }}%</dd>
</div>
<div class="npa-detail-field">
<dt>Outstanding Balance</dt>
<dd>₹{{ a.outstandingBalance | number: '1.0-0' }}</dd>
</div>
<div class="npa-detail-field">
<dt>Overdue Amount</dt>
<dd>₹{{ a.overdueAmount | number: '1.0-0' }}</dd>
</div>
<div class="npa-detail-field">
<dt>Overdue Days</dt>
<dd>{{ a.overdueDays }}</dd>
</div>
<div class="npa-detail-field">
<dt>NPA Since</dt>
<dd>{{ npaSince(a) }}</dd>
</div>
<div class="npa-detail-field">
<dt>Restructured</dt>
<dd>{{ a.isRestructured ? 'Yes' : 'No' }}</dd>
</div>
<div class="npa-detail-field">
<dt>Last Payment Date</dt>
<dd>{{ a.lastPaymentDate }}</dd>
</div>
<div class="npa-detail-field">
<dt>Collateral Value</dt>
<dd>₹{{ a.collateralValue | number: '1.0-0' }}</dd>
</div>
<div class="npa-detail-field">
<dt>Provisioning Rate</dt>
<dd>{{ provisionRate(a) }}%</dd>
</div>
<div class="npa-detail-field">
<dt>Provision Required</dt>
<dd>₹{{ provisionReq(a) | number: '1.0-0' }}</dd>
</div>
<div class="npa-detail-field">
<dt>Provision Held</dt>
<dd>₹{{ provisionHeldAmt(a) | number: '1.0-0' }}</dd>
</div>
<div class="npa-detail-field">
<dt>Interest Reversed</dt>
<dd>₹{{ interestRev(a) | number: '1.0-0' }}</dd>
</div>
<div class="npa-detail-field">
<dt>Total Recovered</dt>
<dd>₹{{ recovered(a) | number: '1.0-0' }}</dd>
</div>
<div class="npa-detail-field">
<dt>Movement (vs Last Quarter)</dt>
<dd>{{ movement(a) }}</dd>
</div>
<div class="npa-detail-field">
<dt>NPA Reason</dt>
<dd>{{ a.npaReason }}</dd>
</div>
<div class="npa-detail-field">
<dt>Recovery Officer</dt>
<dd>{{ a.recoveryOfficer }}</dd>
</div>
<div class="npa-detail-field">
<dt>Legal Action Status</dt>
<dd [class]="legalActionClass(a.legalActionStatus)">{{ legalActionLabel(a.legalActionStatus) }}</dd>
</div>
</dl>
</div>
@if (isNpaStageOf(a)) {
<div class="pg-card">
<div class="pos-section-label">Recovery Action — Update Legal Action Status</div>
<div class="inv-form-actions">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="a.legalActionStatus === 'NOTICE_ISSUED'"
(click)="updateLegalAction(a, 'NOTICE_ISSUED')"
>
Issue Demand Notice
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="a.legalActionStatus === 'SARFAESI_INITIATED'"
(click)="updateLegalAction(a, 'SARFAESI_INITIATED')"
>
Initiate SARFAESI Action
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="a.legalActionStatus === 'SUIT_FILED'"
(click)="updateLegalAction(a, 'SUIT_FILED')"
>
File Recovery Suit
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="a.legalActionStatus === 'OTS_NEGOTIATION'"
(click)="updateLegalAction(a, 'OTS_NEGOTIATION')"
>
Mark OTS Negotiation
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="a.legalActionStatus === 'NONE'"
(click)="updateLegalAction(a, 'NONE')"
>
Reset to No Action
</button>
</div>
</div>
}
<div class="pg-card">
<div class="pos-section-label">Recovery History</div>
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>Mode</th>
<th class="num col-in">Amount</th>
</tr>
</thead>
<tbody>
@for (r of a.recoveries; track r.date + r.amount) {
<tr>
<td>{{ r.date }}</td>
<td>{{ r.mode }}</td>
<td class="num col-in">₹{{ r.amount | number: '1.0-0' }}</td>
</tr>
} @empty {
<tr>
<td colspan="3" class="pg-empty">No recoveries recorded against this account.</td>
</tr>
}
</tbody>
</table>
</div>
}
}
<!-- ===================== 06 NPA MOVEMENT REPORT ===================== -->
@if (activeReport === 'movement') {
<div class="pg-card">
<div class="pos-section-label">Classification Movement — Current Quarter vs Previous Quarter</div>
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Previous Stage</th>
<th>Current Stage</th>
<th>Movement</th>
<th class="num">Outstanding</th>
</tr>
</thead>
<tbody>
@for (a of paged(movedAccounts); 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(prevStage(a))">{{ stageLabel(prevStage(a)) }}</span>
</td>
<td>
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
</td>
<td>{{ movement(a) }}</td>
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="pg-empty">No classification movement in this branch.</td>
</tr>
}
</tbody>
</table>
@if (movedAccounts.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(movedAccounts) }}–{{ rangeEnd(movedAccounts) }} of {{ movedAccounts.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, movedAccounts)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(movedAccounts) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(movedAccounts)"
(click)="goToPage(currentPage + 1, movedAccounts)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== 07 NPA WITH PROVISION AND REQUIRED PROVISION ===================== -->
@if (activeReport === 'provision') {
<div class="pg-card">
<div class="pos-section-label">Provision Held vs Required — Full Loan Book</div>
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Stage</th>
<th class="num">Outstanding</th>
<th class="num">Rate</th>
<th class="num">Provision Required</th>
<th class="num">Provision Held</th>
<th class="num">Shortfall / (Excess)</th>
</tr>
</thead>
<tbody>
@for (a of paged(provisionRows); 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 class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
<td class="num">{{ provisionRate(a) }}%</td>
<td class="num">₹{{ provisionReq(a) | number: '1.0-0' }}</td>
<td class="num">₹{{ provisionHeldAmt(a) | number: '1.0-0' }}</td>
<td class="num" [class.col-out]="shortfall(a) > 0" [class.col-in]="shortfall(a) < 0">
₹{{ shortfall(a) | number: '1.0-0' }}
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="6">Total ({{ provisionRows.length }} accounts)</td>
<td class="num">₹{{ provisionRequiredTotal | number: '1.0-0' }}</td>
<td class="num">₹{{ provisionHeldTotal | number: '1.0-0' }}</td>
<td class="num">₹{{ provisionShortfallTotal | number: '1.0-0' }}</td>
</tr>
</tfoot>
</table>
@if (provisionRows.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(provisionRows) }}–{{ rangeEnd(provisionRows) }} of {{ provisionRows.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, provisionRows)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(provisionRows) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(provisionRows)"
(click)="goToPage(currentPage + 1, provisionRows)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== 08 PROBABLE NPA ===================== -->
@if (activeReport === 'probable') {
<div class="pg-card">
<div class="pos-section-label">
Probable NPA — SMA-2 Accounts Likely to Slip Next Quarter (61–90 Days Overdue)
</div>
<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">Overdue Amount</th>
<th class="num">Overdue Days</th>
<th>Last Payment</th>
<th>Follow-up</th>
</tr>
</thead>
<tbody>
@for (a of paged(probableNpaAccounts); 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 col-out">₹{{ a.overdueAmount | number: '1.0-0' }}</td>
<td class="num">{{ a.overdueDays }}</td>
<td>{{ a.lastPaymentDate }}</td>
<td>
<span
class="npa-badge"
[class.npa-badge--sma2]="a.overdueDays > 75"
[class.npa-badge--sma0]="a.overdueDays <= 75"
>
{{ a.overdueDays > 75 ? 'Urgent' : 'Monitor' }}
</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="pg-empty">No SMA-2 accounts in this branch.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="4">Total ({{ probableNpaAccounts.length }} accounts)</td>
<td class="num">₹{{ sumOutstanding(probableNpaAccounts) | number: '1.0-0' }}</td>
<td colspan="4"></td>
</tr>
</tfoot>
</table>
@if (probableNpaAccounts.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(probableNpaAccounts) }}–{{ rangeEnd(probableNpaAccounts) }} of
{{ probableNpaAccounts.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, probableNpaAccounts)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(probableNpaAccounts) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(probableNpaAccounts)"
(click)="goToPage(currentPage + 1, probableNpaAccounts)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== 09 SMA REPORT ===================== -->
@if (activeReport === 'sma') {
<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>
<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">Overdue Amount</th>
<th class="num">Overdue Days</th>
</tr>
</thead>
<tbody>
@for (a of paged(smaAccounts); 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 col-out">₹{{ a.overdueAmount | number: '1.0-0' }}</td>
<td class="num">{{ a.overdueDays }}</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="pg-empty">No SMA accounts in this branch.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="5">Total ({{ smaAccounts.length }} accounts)</td>
<td class="num">₹{{ sumOutstanding(smaAccounts) | number: '1.0-0' }}</td>
<td class="num">₹{{ sumOverdue(smaAccounts) | number: '1.0-0' }}</td>
<td></td>
</tr>
</tfoot>
</table>
@if (smaAccounts.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(smaAccounts) }}–{{ rangeEnd(smaAccounts) }} of {{ smaAccounts.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, smaAccounts)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(smaAccounts) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(smaAccounts)"
(click)="goToPage(currentPage + 1, smaAccounts)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
</div>
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
@@ -0,0 +1,490 @@
/**
* 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 { AuthenticationService } from '../../core/authentication/authentication.service';
import {
LOAN_ACCOUNTS,
BRANCHES,
REPORT_META,
GROUP_LABELS,
DEFAULT_REPORT_ID,
LoanAccount,
NpaStage,
ReportGroupId,
STAGE_ORDER,
STAGE_LABELS,
currentStage,
previousStage,
isNpaStage,
isSmaStage,
provisionRatePct,
provisionRequired,
provisionHeld,
interestReversed,
totalRecovered,
movementLabel,
stageBadgeClass,
npaSinceDate,
todayDDMMYYYY,
LegalActionStatus,
LEGAL_ACTION_LABELS
} from '../npa-data';
interface StageSummaryRow {
stage: NpaStage;
label: string;
count: number;
outstanding: number;
provisionReq: number;
}
interface BranchSummaryRow {
branchCode: string;
branchName: string;
totalAccounts: number;
totalAdvances: number;
npaCount: number;
npaOutstanding: number;
provisionReq: number;
gnpaRatio: number;
}
interface RecoveryRow {
accountNo: string;
customerName: string;
branchName: string;
date: string;
amount: number;
mode: string;
}
/**
* The legacy "NPA" mega-menu — 9 screens across NPA Reports / NPA Analysis / NPA Classification —
* collapsed into one route-param-driven component so every menu item lands on its own URL
* (/npa/reports/:reportId) with nothing left unwired. Every figure is derived from the single
* LOAN_ACCOUNTS book in npa-data.ts, so a count on one report always agrees with another.
*/
@Component({
selector: 'mifosx-npa-reports',
templateUrl: './npa-reports.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../../investment/investment-shared.scss',
'../npa-shared.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule
]
})
export class NpaReportsComponent implements OnInit, OnDestroy {
readonly reportMeta = REPORT_META;
readonly groupLabels = GROUP_LABELS;
readonly branches = BRANCHES;
readonly stageOrder = STAGE_ORDER;
readonly stageLabels = STAGE_LABELS;
asOnDate = todayDDMMYYYY();
activeGroup: ReportGroupId = 'reports';
activeReport = DEFAULT_REPORT_ID;
/** True for a Head Office login — sees a branch selector + the consolidated "All Branches"
* view with a branch-wise break-up. A branch-level login is locked to its own branch, the
* same split used by the Cash/Clearing/Transfer Summary report. */
isHeadOffice = false;
/** The logged-in user's own branch/office name, from real session credentials. */
currentOfficeName = 'Branch Office';
/** Set when a branch-level login's officeName matches one of the demo branches below. */
private ownBranchCode: string | null = null;
/** '' = All Branches (Head Office consolidated view); otherwise a BranchMaster.code. */
branchFilter = '';
/** Pagination — shared across whichever report is currently on screen, since only one
* report's table is ever visible at a time. Reset to page 1 whenever the underlying list
* can change size (report switch, branch switch, Generate). */
readonly pageSize = 20;
currentPage = 1;
selectedAccountNo = '';
newRecovery = {
accountNo: '',
date: this.asOnDate,
amount: null as number | null,
mode: 'Cash Deposit'
};
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_REPORT_ID;
const meta = this.reportMeta.find((m) => m.id === id) || this.reportMeta[3];
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) {
// Head Office starts on the consolidated view and can drill into any branch.
this.branchFilter = '';
} else {
// Match the logged-in office's real name to one of the demo branches, so each branch's
// login sees only its own book. Falls back to the first demo branch (still labelled with
// the user's real office name) if no branch name matches.
this.ownBranchCode =
this.branches.find((b) => this.currentOfficeName.toLowerCase().includes(b.name.toLowerCase()))?.code || null;
this.branchFilter = this.ownBranchCode || this.branches[0].code;
}
const firstNpa = this.npaAccounts[0];
this.selectedAccountNo = firstNpa ? firstNpa.accountNo : this.allAccounts[0]?.accountNo || '';
}
ngOnDestroy(): void {
this.sub?.unsubscribe();
}
/** 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)';
}
setBranch(code: string): void {
this.branchFilter = code;
this.currentPage = 1;
}
// -------------------- Pagination (shared by every report's table) --------------------
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));
}
goToReport(reportId: string): void {
this.router.navigate([
'/npa/reports',
reportId
]);
}
groupOf(group: string) {
return this.reportMeta.filter((m) => m.group === group);
}
reportLabel(id: string): string {
return this.reportMeta.find((m) => m.id === id)?.label || id;
}
stage(a: LoanAccount): NpaStage {
return currentStage(a);
}
prevStage(a: LoanAccount): NpaStage {
return previousStage(a);
}
stageLabel(stage: NpaStage): string {
return STAGE_LABELS[stage];
}
badgeClass(stage: NpaStage): string {
return stageBadgeClass(stage);
}
provisionRate(a: LoanAccount): number {
return provisionRatePct(this.stage(a), a.secured);
}
provisionReq(a: LoanAccount): number {
return provisionRequired(a);
}
provisionHeldAmt(a: LoanAccount): number {
return provisionHeld(a);
}
interestRev(a: LoanAccount): number {
return interestReversed(a);
}
recovered(a: LoanAccount): number {
return totalRecovered(a);
}
movement(a: LoanAccount): string {
return movementLabel(this.prevStage(a), this.stage(a));
}
npaSince(a: LoanAccount): string {
return npaSinceDate(a);
}
legalActionLabel(status: LegalActionStatus): string {
return LEGAL_ACTION_LABELS[status];
}
legalActionClass(status: LegalActionStatus): string {
return status === 'SARFAESI_INITIATED' || status === 'SUIT_FILED' ? 'col-out' : '';
}
updateLegalAction(a: LoanAccount, status: LegalActionStatus): void {
a.legalActionStatus = status;
this.toast(`${this.legalActionLabel(status)} — recorded for ${a.accountNo}.`);
}
isNpaStageOf(a: LoanAccount): boolean {
return isNpaStage(this.stage(a));
}
get allAccounts(): LoanAccount[] {
return this.branchFilter ? LOAN_ACCOUNTS.filter((a) => a.branchCode === this.branchFilter) : LOAN_ACCOUNTS;
}
get npaAccounts(): LoanAccount[] {
return this.allAccounts.filter((a) => isNpaStage(this.stage(a)));
}
get smaAccounts(): LoanAccount[] {
return this.allAccounts.filter((a) => isSmaStage(this.stage(a)));
}
get probableNpaAccounts(): LoanAccount[] {
return this.allAccounts.filter((a) => this.stage(a) === 'SMA2').sort((a, b) => b.overdueDays - a.overdueDays);
}
get movedAccounts(): LoanAccount[] {
return this.allAccounts
.filter((a) => this.prevStage(a) !== this.stage(a))
.sort((a, b) => STAGE_ORDER.indexOf(this.stage(b)) - STAGE_ORDER.indexOf(this.stage(a)));
}
get recoveryAccounts(): LoanAccount[] {
return this.npaAccounts.filter((a) => a.recoveries.length > 0);
}
get recoveryOptions(): LoanAccount[] {
return this.npaAccounts;
}
get recoveryRows(): RecoveryRow[] {
return this.recoveryAccounts
.flatMap((a) =>
a.recoveries.map((r) => ({
accountNo: a.accountNo,
customerName: a.customerName,
branchName: a.branchName,
date: r.date,
amount: r.amount,
mode: r.mode
}))
)
.sort((a, b) => b.amount - a.amount);
}
// -------------------- Portfolio-wide KPIs (shown on every screen) --------------------
get totalAdvances(): number {
return this.allAccounts.reduce((s, a) => s + a.outstandingBalance, 0);
}
get gnpaAmount(): number {
return this.npaAccounts.reduce((s, a) => s + a.outstandingBalance, 0);
}
get gnpaRatio(): number {
return this.totalAdvances ? (this.gnpaAmount / this.totalAdvances) * 100 : 0;
}
get totalNpaProvisionHeld(): number {
return this.npaAccounts.reduce((s, a) => s + this.provisionHeldAmt(a), 0);
}
get netNpaAmount(): number {
return Math.max(0, this.gnpaAmount - this.totalNpaProvisionHeld);
}
get nnpaRatio(): number {
return this.totalAdvances ? (this.netNpaAmount / this.totalAdvances) * 100 : 0;
}
get totalRecoveredAmount(): number {
return this.npaAccounts.reduce((s, a) => s + this.recovered(a), 0);
}
get restructuredAccounts(): LoanAccount[] {
return this.allAccounts.filter((a) => a.isRestructured);
}
get restructuredOutstanding(): number {
return this.restructuredAccounts.reduce((s, a) => s + a.outstandingBalance, 0);
}
// -------------------- NPA Summary Report: stage-wise + branch-wise cuts --------------------
get stageSummary(): StageSummaryRow[] {
return STAGE_ORDER.map((stage) => {
const accts = this.allAccounts.filter((a) => this.stage(a) === stage);
return {
stage,
label: STAGE_LABELS[stage],
count: accts.length,
outstanding: accts.reduce((s, a) => s + a.outstandingBalance, 0),
provisionReq: accts.reduce((s, a) => s + this.provisionReq(a), 0)
};
});
}
get branchSummary(): BranchSummaryRow[] {
return this.branches.map((b) => {
const all = LOAN_ACCOUNTS.filter((a) => a.branchCode === b.code);
const npa = all.filter((a) => isNpaStage(this.stage(a)));
const totalAdvances = all.reduce((s, a) => s + a.outstandingBalance, 0);
const npaOutstanding = npa.reduce((s, a) => s + a.outstandingBalance, 0);
return {
branchCode: b.code,
branchName: b.name,
totalAccounts: all.length,
totalAdvances,
npaCount: npa.length,
npaOutstanding,
provisionReq: npa.reduce((s, a) => s + this.provisionReq(a), 0),
gnpaRatio: totalAdvances ? (npaOutstanding / totalAdvances) * 100 : 0
};
});
}
get grandTotalAccounts(): number {
return this.allAccounts.length;
}
get grandTotalOutstanding(): number {
return this.allAccounts.reduce((s, a) => s + a.outstandingBalance, 0);
}
get grandTotalProvision(): number {
return this.allAccounts.reduce((s, a) => s + this.provisionReq(a), 0);
}
get bankWideTotalAccounts(): number {
return LOAN_ACCOUNTS.length;
}
// -------------------- NPA with Provision and Required Provision --------------------
get provisionRows(): LoanAccount[] {
return [...this.allAccounts].sort((a, b) => this.provisionReq(b) - this.provisionReq(a));
}
get provisionRequiredTotal(): number {
return this.allAccounts.reduce((s, a) => s + this.provisionReq(a), 0);
}
get provisionHeldTotal(): number {
return this.allAccounts.reduce((s, a) => s + this.provisionHeldAmt(a), 0);
}
get provisionShortfallTotal(): number {
return this.provisionRequiredTotal - this.provisionHeldTotal;
}
shortfall(a: LoanAccount): number {
return this.provisionReq(a) - this.provisionHeldAmt(a);
}
// -------------------- NPA Account Detail Report --------------------
get selectedAccount(): LoanAccount | undefined {
return LOAN_ACCOUNTS.find((a) => a.accountNo === this.selectedAccountNo);
}
searchAccount(): void {
const a = this.selectedAccount;
if (!a) {
this.toast('No account found with that number.');
return;
}
this.toast(`Loaded full NPA details for ${a.accountNo} — ${a.customerName}.`);
}
// -------------------- NPA Recovery Report: add recovery entry --------------------
addRecovery(): void {
const acc = LOAN_ACCOUNTS.find((a) => a.accountNo === this.newRecovery.accountNo);
if (!acc || !this.newRecovery.amount || this.newRecovery.amount <= 0) {
this.toast('Select an NPA account and enter a valid recovery amount.');
return;
}
acc.recoveries = [
{ date: this.newRecovery.date || this.asOnDate, amount: this.newRecovery.amount, mode: this.newRecovery.mode },
...acc.recoveries
];
this.toast(`Recovery of ₹${this.newRecovery.amount.toLocaleString('en-IN')} recorded against ${acc.accountNo}.`);
this.newRecovery = { accountNo: '', date: this.asOnDate, amount: null, mode: 'Cash Deposit' };
}
sumOutstanding(accounts: LoanAccount[]): number {
return accounts.reduce((s, a) => s + a.outstandingBalance, 0);
}
sumOverdue(accounts: LoanAccount[]): number {
return accounts.reduce((s, a) => s + a.overdueAmount, 0);
}
sumInterest(accounts: LoanAccount[]): number {
return accounts.reduce((s, a) => s + this.interestRev(a), 0);
}
generate(): void {
this.currentPage = 1;
this.toast(`${this.reportLabel(this.activeReport)} generated as on ${this.asOnDate}.`);
}
private toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
if (this.toastTimer) {
clearTimeout(this.toastTimer);
}
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
}
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { NpaReportsComponent } from './npa-reports/npa-reports.component';
import { DEFAULT_REPORT_ID } from './npa-data';
const routes: Routes = [
Route.withShell([
{
path: 'npa',
data: { title: 'NPA', breadcrumb: 'NPA' },
children: [
{
path: 'reports',
redirectTo: `reports/${DEFAULT_REPORT_ID}`,
pathMatch: 'full'
},
{
path: 'reports/:reportId',
component: NpaReportsComponent,
data: { title: 'NPA Reports', breadcrumb: 'NPA Reports' }
}
]
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class NpaRoutingModule {}
+153
View File
@@ -0,0 +1,153 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
// NPA-specific status colouring layered on top of ../cashier/cashier-shared.scss (pg-*, cctr-*)
// and ../investment/investment-shared.scss (inv-kpis, inv-form-*, inv-toast — reused as-is since
// they are the same generic "KPI tile / entry form / toast" primitives, not Investment-specific).
.npa-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
&--standard {
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
}
&--sma0 {
background: #fefce8;
color: #854d0e;
border: 1px solid #fde68a;
}
&--sma1 {
background: #fff7ed;
color: #9a3412;
border: 1px solid #fed7aa;
}
&--sma2 {
background: #fff1f0;
color: #b91c1c;
border: 1px solid #fecdd3;
}
&--substandard {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
&--doubtful1 {
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fca5a5;
}
&--doubtful2 {
background: #fdf2f8;
color: #9d174d;
border: 1px solid #fbcfe8;
}
&--doubtful3 {
background: #f5f3ff;
color: #5b21b6;
border: 1px solid #ddd6fe;
}
&--loss {
background: #1f2937;
color: #f9fafb;
border: 1px solid #1f2937;
}
}
.npa-kpis--ratio {
.inv-kpi__value {
&.npa-warn {
color: #dc2626;
}
}
}
.npa-detail-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px 20px;
padding: 18px;
}
.npa-detail-field {
display: flex;
flex-direction: column;
gap: 3px;
dt {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #64748b;
}
dd {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #0d1b2a;
font-variant-numeric: tabular-nums;
}
}
// Wraps tables with more columns than comfortably fit — .pg-card clips overflow with
// border-radius, so the scroll container has to live one level in, around the <table> only.
.pg-table-scroll {
overflow-x: auto;
}
// Lets a single field span the whole .inv-form-grid row instead of being squeezed into one
// ~220px column — needed for the Account Detail search box, whose "code — name (branch)"
// option text is long enough to overflow a narrow column even with ellipsis truncation.
.npa-form-field--full {
grid-column: 1 / -1;
}
.pg-pagination {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
padding: 10px 16px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
font-size: 12.5px;
color: #64748b;
&__controls {
display: flex;
align-items: center;
gap: 10px;
}
&__page {
font-weight: 600;
color: #0d1b2a;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { NpaRoutingModule } from './npa-routing.module';
import { NpaReportsComponent } from './npa-reports/npa-reports.component';
@NgModule({
imports: [
SharedModule,
NpaRoutingModule,
NpaReportsComponent
]
})
export class NpaModule {}
@@ -0,0 +1,87 @@
<!--
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="container aml-rules-container">
<mat-card class="aml-card">
<mat-card-header class="aml-card-header">
<div class="aml-header-content">
<mat-icon class="header-icon">shield</mat-icon>
<div>
<mat-card-title class="header-title">AML Rules Master</mat-card-title>
<mat-card-subtitle class="header-subtitle">
Database-driven rules for client risk categorization and AML compliance checks
</mat-card-subtitle>
</div>
</div>
</mat-card-header>
<mat-card-content class="aml-card-content">
<table mat-table [dataSource]="rules" class="aml-table mat-elevation-z1">
<!-- Order Position Column -->
<ng-container matColumnDef="orderPosition">
<th mat-header-cell *matHeaderCellDef class="col-center">Rule Srno</th>
<td mat-cell *matCellDef="let element" class="col-center">{{ element.orderPosition }}</td>
</ng-container>
<!-- Rule Name Column -->
<ng-container matColumnDef="ruleName">
<th mat-header-cell *matHeaderCellDef>Rule Description</th>
<td mat-cell *matCellDef="let element" class="rule-name-cell">{{ element.ruleName }}</td>
</ng-container>
<!-- Risk Category Column -->
<ng-container matColumnDef="riskCategory">
<th mat-header-cell *matHeaderCellDef class="col-center">Risk Category</th>
<td mat-cell *matCellDef="let element" class="col-center">
<span class="risk-badge" [ngClass]="getRiskColorClass(element.riskCategory)">
<span class="dot"></span>
{{ getRiskCategoryLabel(element.riskCategory) }}
</span>
</td>
</ng-container>
<!-- Reason Column -->
<ng-container matColumnDef="reason">
<th mat-header-cell *matHeaderCellDef>Rule Reason / Criteria</th>
<td mat-cell *matCellDef="let element" class="reason-cell">{{ element.reason }}</td>
</ng-container>
<!-- AML Check Status Column -->
<ng-container matColumnDef="amlCheckStatus">
<th mat-header-cell *matHeaderCellDef class="col-center">AML Check Status</th>
<td mat-cell *matCellDef="let element" class="col-center">
<span class="compliance-badge" [ngClass]="'status-' + (element.amlCheckStatus || 'clear').toLowerCase()">
{{ element.amlCheckStatus || 'CLEAR' }}
</span>
</td>
</ng-container>
<!-- Banned Entity Match Column -->
<ng-container matColumnDef="bannedEntityMatch">
<th mat-header-cell *matHeaderCellDef class="col-center">Banned Match</th>
<td mat-cell *matCellDef="let element" class="col-center">
<span class="compliance-badge" [ngClass]="'match-' + (element.bannedEntityMatch || 'no').toLowerCase()">
{{ element.bannedEntityMatch || 'NO' }}
</span>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="rule-row"></tr>
</table>
<!-- Mat Paginator for standard pagination -->
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons class="aml-paginator"></mat-paginator>
<div *ngIf="rules.data.length === 0" class="no-rules-message">
<mat-icon>info_outline</mat-icon>
<span>No AML rules found in the database.</span>
</div>
</mat-card-content>
</mat-card>
</div>
@@ -0,0 +1,228 @@
/**
* 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/.
*/
.aml-rules-container {
padding: 24px;
background-color: var(--bg-neutral-light, #f8fafc);
min-height: calc(100vh - 64px);
}
.aml-card {
border-radius: 12px;
box-shadow:
0 4px 20px -2px rgb(0 0 0 / 5%),
0 2px 8px -1px rgb(0 0 0 / 3%);
border: 1px solid rgb(226 232 240 / 80%);
background: #fff;
overflow: hidden;
}
.aml-card-header {
background: linear-gradient(135deg, #1e293b, #0f172a);
color: #fff;
padding: 24px;
}
.aml-header-content {
display: flex;
align-items: center;
gap: 16px;
.header-icon {
font-size: 32px;
width: 32px;
height: 32px;
color: #38bdf8;
}
.header-title {
font-size: 20px;
font-weight: 600;
letter-spacing: -0.02em;
color: #fff;
margin-bottom: 4px;
}
.header-subtitle {
font-size: 13px;
color: #94a3b8;
}
}
.aml-card-content {
padding: 24px;
}
.aml-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e2e8f0;
th {
background-color: #f1f5f9;
color: #475569;
font-weight: 600;
font-size: 13px;
padding: 16px;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid #cbd5e1;
}
td {
padding: 16px;
font-size: 14px;
color: #334155;
border-bottom: 1px solid #e2e8f0;
}
.rule-row {
transition: background-color 0.2s ease;
&:hover {
background-color: #f8fafc;
}
&:last-child td {
border-bottom: none;
}
}
}
.col-center {
text-align: center;
justify-content: center;
}
.rule-name-cell {
font-weight: 500;
color: #1e293b;
max-width: 300px;
}
.reason-cell {
color: #64748b;
font-size: 13px;
max-width: 400px;
}
/* Badges for Risk Category */
.risk-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
&.risk-high {
background-color: #fef2f2;
color: #ef4444;
border: 1px solid #fee2e2;
.dot {
background-color: #ef4444;
}
}
&.risk-medium {
background-color: #fffbeb;
color: #d97706;
border: 1px solid #fef3c7;
.dot {
background-color: #d97706;
}
}
&.risk-low {
background-color: #f0fdf4;
color: #16a34a;
border: 1px solid #dcfce7;
.dot {
background-color: #16a34a;
}
}
}
/* Badges for Compliance status */
.compliance-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
/* AML Statuses */
&.status-clear {
background-color: #dcfce7;
color: #15803d;
}
&.status-flagged {
background-color: #fef3c7;
color: #b45309;
}
&.status-blocked {
background-color: #fee2e2;
color: #b91c1c;
}
/* Banned Matches */
&.match-no {
background-color: #f1f5f9;
color: #475569;
}
&.match-yes {
background-color: #fee2e2;
color: #b91c1c;
}
&.match-partial_match {
background-color: #ffedd5;
color: #c2410c;
}
}
.no-rules-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
color: #64748b;
gap: 12px;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
}
}
.aml-paginator {
background: #fff;
border-top: 1px solid #e2e8f0;
padding: 8px 0;
font-size: 13px;
color: #475569;
}
@@ -0,0 +1,87 @@
/**
* 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, ViewChild } from '@angular/core';
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
import { CommonModule } from '@angular/common';
/** Custom Services */
import { ClientsService } from '../../clients/clients.service';
@Component({
selector: 'mifosx-aml-rules',
templateUrl: './aml-rules.component.html',
styleUrls: ['./aml-rules.component.scss'],
standalone: true,
imports: [
CommonModule,
MatTableModule,
MatCardModule,
MatIconModule,
MatButtonModule,
MatCheckboxModule,
MatPaginatorModule
]
})
export class AmlRulesComponent implements OnInit {
private clientsService = inject(ClientsService);
rules = new MatTableDataSource<any>([]);
displayedColumns: string[] = [
'orderPosition',
'ruleName',
'riskCategory',
'reason',
'amlCheckStatus',
'bannedEntityMatch'
];
@ViewChild(MatPaginator) paginator!: MatPaginator;
ngOnInit(): void {
this.clientsService.getClientRiskRules().subscribe({
next: (data) => {
this.rules.data = data || [];
this.rules.paginator = this.paginator;
},
error: () => {}
});
}
getRiskCategoryLabel(category: number): string {
switch (category) {
case 1:
return 'Low Risk';
case 2:
return 'Medium Risk';
case 3:
return 'High Risk';
default:
return 'Unknown';
}
}
getRiskColorClass(category: number): string {
switch (category) {
case 1:
return 'risk-low';
case 2:
return 'risk-medium';
case 3:
return 'risk-high';
default:
return '';
}
}
}
@@ -11,6 +11,7 @@ import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { RbiCircularsComponent } from './rbi-circulars.component';
import { AmlRulesComponent } from './aml-rules/aml-rules.component';
const routes: Routes = [
Route.withShell([
@@ -18,6 +19,11 @@ const routes: Routes = [
path: 'rbi-circulars',
component: RbiCircularsComponent,
data: { title: 'RBI Circulars', breadcrumb: 'RBI Circulars' }
},
{
path: 'rbi/aml',
component: AmlRulesComponent,
data: { title: 'AML Rules Master', breadcrumb: 'AML Rules Master' }
}
])
];
+11 -11
View File
@@ -141,11 +141,11 @@ export interface SftInterestDividendRecord {
}
export const SFT_BANK = {
name: 'Bharat Urban Bank Ltd',
name: 'CBSNexora Bank Ltd',
branch: 'Head Office',
filerPan: 'AABCB1100M',
filerTan: 'MUMR99001E',
itdReportingPortalId: 'BHARATURBAN-285BA',
itdReportingPortalId: 'CBSNEXORA-285BA',
fy: '2025-26',
filingDueDate: '31-05-2026',
principalOfficer: 'Karthik Kamath',
@@ -159,7 +159,7 @@ export const SFT_BANK = {
branchStdCode: '022',
branchPhone: '24938000',
branchMobile: '9892000100',
branchEmail: 'compliance@bharaturbanbank.example'
branchEmail: 'compliance@cbsnexorabank.example'
};
/** ── SFT-001 / 002 / 004 — Person-wise records ── */
@@ -202,7 +202,7 @@ export const SFT_PERSON_RECORDS: SftPersonRecord[] = [
cashAmountReceivedFromPerson: 1500000,
amountPaidToPerson: 0,
relatedAccountNumber: '110001101000838',
relatedInstituteName: 'BHARAT URBAN BANK',
relatedInstituteName: 'CBSNEXORA BANK',
relatedInstituteRefNumber: 'YESB0BHRT',
sftCode: 'SFT-001'
},
@@ -243,7 +243,7 @@ export const SFT_PERSON_RECORDS: SftPersonRecord[] = [
cashAmountReceivedFromPerson: 1100000,
amountPaidToPerson: 0,
relatedAccountNumber: '110001352000357',
relatedInstituteName: 'BHARAT URBAN BANK',
relatedInstituteName: 'CBSNEXORA BANK',
relatedInstituteRefNumber: 'YESB0BHRT',
sftCode: 'SFT-001'
},
@@ -285,7 +285,7 @@ export const SFT_PERSON_RECORDS: SftPersonRecord[] = [
cashAmountReceivedFromPerson: 2500000,
amountPaidToPerson: 0,
relatedAccountNumber: '110001352000420',
relatedInstituteName: 'BHARAT URBAN BANK',
relatedInstituteName: 'CBSNEXORA BANK',
relatedInstituteRefNumber: 'YESB0BHRT',
sftCode: 'SFT-002'
},
@@ -326,7 +326,7 @@ export const SFT_PERSON_RECORDS: SftPersonRecord[] = [
cashAmountReceivedFromPerson: 1800000,
amountPaidToPerson: 0,
relatedAccountNumber: '110001352000511',
relatedInstituteName: 'BHARAT URBAN BANK',
relatedInstituteName: 'CBSNEXORA BANK',
relatedInstituteRefNumber: 'YESB0BHRT',
sftCode: 'SFT-002'
},
@@ -368,7 +368,7 @@ export const SFT_PERSON_RECORDS: SftPersonRecord[] = [
cashAmountReceivedFromPerson: 1250000,
amountPaidToPerson: 0,
relatedAccountNumber: '110001101001145',
relatedInstituteName: 'BHARAT URBAN BANK',
relatedInstituteName: 'CBSNEXORA BANK',
relatedInstituteRefNumber: 'YESB0BHRT',
sftCode: 'SFT-004'
},
@@ -409,7 +409,7 @@ export const SFT_PERSON_RECORDS: SftPersonRecord[] = [
cashAmountReceivedFromPerson: 2150000,
amountPaidToPerson: 0,
relatedAccountNumber: '110001101001458',
relatedInstituteName: 'BHARAT URBAN BANK',
relatedInstituteName: 'CBSNEXORA BANK',
relatedInstituteRefNumber: 'YESB0BHRT',
sftCode: 'SFT-004'
}
@@ -436,7 +436,7 @@ export const SFT_ACCOUNT_RECORDS: SftAccountRecord[] = [
branchMobile: '9892000100',
branchFaxStd: '022',
branchFaxPhone: '24938011',
branchEmail: 'compliance@bharaturbanbank.example',
branchEmail: 'compliance@cbsnexorabank.example',
aggGrossCashCredited: 6500000,
aggGrossCashDebited: 4200000,
amountCreditedBeforeDemonetization: 0,
@@ -485,7 +485,7 @@ export const SFT_ACCOUNT_RECORDS: SftAccountRecord[] = [
branchMobile: '9892000100',
branchFaxStd: '022',
branchFaxPhone: '24938011',
branchEmail: 'compliance@bharaturbanbank.example',
branchEmail: 'compliance@cbsnexorabank.example',
aggGrossCashCredited: 5800000,
aggGrossCashDebited: 3450000,
amountCreditedBeforeDemonetization: 0,
+1 -1
View File
@@ -10,7 +10,7 @@
export default {
'mifos_x': {
'version': '260731',
'hash': 'ca85bae4'
'hash': 'fd28788d'
},
'allow_switching_backend_instance': true
};
+16
View File
@@ -1017,6 +1017,22 @@ mat-card {
}
}
/* ─── Full-Screen Dialog Panel (e.g. FD/RD Entry) — edge-to-edge, no rounded corners ─── */
.fdrd-fullscreen-panel {
max-width: 100vw !important;
width: 100vw !important;
height: 100vh !important;
max-height: 100vh !important;
.mat-mdc-dialog-container {
border-radius: 0 !important;
}
.mat-mdc-dialog-surface {
border-radius: 0 !important;
}
}
/* ─── CIC DQR Dialog Backdrop — ensure outside clicks register ─── */
.cic-dlg-backdrop {
background: rgb(15 23 42 / 45%);