1760 lines
50 KiB
TypeScript
1760 lines
50 KiB
TypeScript
/**
|
||
* 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 { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||
import { Subscription } from 'rxjs';
|
||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||
import { ExportSheetSpec, exportWorkbook } from '../../cashier/cashier-export';
|
||
import { SearchSelectComponent, SearchSelectOption } from '../search-select/search-select.component';
|
||
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,
|
||
daysSinceNpa,
|
||
interestRecoveredCash,
|
||
undebitedInterestBalance,
|
||
todayDDMMYYYY,
|
||
LegalActionStatus,
|
||
LEGAL_ACTION_LABELS,
|
||
RepaymentType,
|
||
REPAYMENT_TYPE_LABELS,
|
||
isBulletMatured,
|
||
isBulletOverdue,
|
||
isOverdrawnBreach,
|
||
expectedRecoveryThisMonth,
|
||
actualRecoveryThisMonth,
|
||
securityCoverageRatio,
|
||
isUndersecured,
|
||
RecoveryEvent,
|
||
RecoveryEventType
|
||
} 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;
|
||
productName: string;
|
||
npaCategory: string;
|
||
npaCategoryClass: string;
|
||
date: string;
|
||
amount: number;
|
||
mode: string;
|
||
/** What the account was contractually due to pay this month (EMI / interest-servicing /
|
||
* bullet), the same "Target" basis used by the Recovery Management Dashboard — shown
|
||
* alongside the actual recovered amount so a desk can see over/under-achievement per entry. */
|
||
targetRecovery: number;
|
||
}
|
||
|
||
interface RepaymentTypeSummaryRow {
|
||
type: RepaymentType;
|
||
label: string;
|
||
accounts: number;
|
||
overdueCount: number;
|
||
expected: number;
|
||
actual: number;
|
||
}
|
||
|
||
interface AgeingBucketRow {
|
||
label: string;
|
||
count: number;
|
||
outstanding: number;
|
||
overdueAmt: number;
|
||
}
|
||
|
||
/**
|
||
* The legacy "NPA" mega-menu — screens across NPA Reports / NPA Analysis / NPA Classification /
|
||
* Recovery Management — 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,
|
||
FontAwesomeModule,
|
||
SearchSelectComponent
|
||
]
|
||
})
|
||
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 = '';
|
||
/** Opens the Recovery / Collection Journey popup for whichever account is currently selected. */
|
||
showTimelineModal = false;
|
||
|
||
newRecovery = {
|
||
accountNo: '',
|
||
date: this.asOnDate,
|
||
amount: null as number | null,
|
||
mode: 'Cash Deposit'
|
||
};
|
||
|
||
/** True while an Export to Excel workbook is being built — disables the button to prevent double-clicks. */
|
||
generating = false;
|
||
|
||
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);
|
||
}
|
||
|
||
daysSinceNpaOf(a: LoanAccount): number {
|
||
return daysSinceNpa(a);
|
||
}
|
||
|
||
interestRecoveredCashOf(a: LoanAccount): number {
|
||
return interestRecoveredCash(a);
|
||
}
|
||
|
||
undebitedInterestBalanceOf(a: LoanAccount): number {
|
||
return undebitedInterestBalance(a);
|
||
}
|
||
|
||
// -------------------- Recovery Management System --------------------
|
||
repaymentTypeLabel(type: RepaymentType): string {
|
||
return REPAYMENT_TYPE_LABELS[type];
|
||
}
|
||
|
||
expectedThisMonth(a: LoanAccount): number {
|
||
return expectedRecoveryThisMonth(a);
|
||
}
|
||
|
||
actualThisMonth(a: LoanAccount): number {
|
||
return actualRecoveryThisMonth(a);
|
||
}
|
||
|
||
recoveryVariance(a: LoanAccount): number {
|
||
return this.actualThisMonth(a) - this.expectedThisMonth(a);
|
||
}
|
||
|
||
/** The per-repayment-type "is this account currently irregular" check the note calls for:
|
||
* an EMI shortfall this cycle, a drawing-power breach, or a matured-unpaid bullet loan. */
|
||
isTypeOverdue(a: LoanAccount): boolean {
|
||
if (a.repaymentType === 'OVERDRAWN') {
|
||
return isOverdrawnBreach(a);
|
||
}
|
||
if (a.repaymentType === 'BULLET') {
|
||
return isBulletOverdue(a);
|
||
}
|
||
return a.overdueDays > 0;
|
||
}
|
||
|
||
overdrawnBreachOf(a: LoanAccount): boolean {
|
||
return isOverdrawnBreach(a);
|
||
}
|
||
|
||
excessOverDp(a: LoanAccount): number {
|
||
return Math.max(0, a.outstandingBalance - a.drawingPower);
|
||
}
|
||
|
||
bulletMaturedOf(a: LoanAccount): boolean {
|
||
return isBulletMatured(a);
|
||
}
|
||
|
||
bulletOverdueOf(a: LoanAccount): boolean {
|
||
return isBulletOverdue(a);
|
||
}
|
||
|
||
coverageRatio(a: LoanAccount): number {
|
||
return securityCoverageRatio(a);
|
||
}
|
||
|
||
undersecuredOf(a: LoanAccount): boolean {
|
||
return isUndersecured(a);
|
||
}
|
||
|
||
securityShortfall(a: LoanAccount): number {
|
||
return Math.max(0, a.outstandingBalance - a.collateralMarketValue);
|
||
}
|
||
|
||
/** CSS class for a collection-timeline event — "Payment Received" reads positive, formal
|
||
* escalation steps (notice/agent) read as an alert, everything else is neutral follow-up. */
|
||
/** Groups a timeline event into one of 3 visual tones for the popup's icon badge —
|
||
* informational follow-up, an escalation step, or an actual payment. */
|
||
timelineEventTone(type: RecoveryEventType): 'positive' | 'alert' | 'neutral' {
|
||
if (type === 'Payment Received') {
|
||
return 'positive';
|
||
}
|
||
if (type === 'Formal Overdue Letter' || type === 'Recovery Agent Assigned' || type === 'Demand Notice') {
|
||
return 'alert';
|
||
}
|
||
return 'neutral';
|
||
}
|
||
|
||
timelineEventIcon(type: RecoveryEventType): string {
|
||
switch (type) {
|
||
case 'SMS Reminder':
|
||
return 'comment-dots';
|
||
case 'IVR / Auto Call':
|
||
case 'Tele-calling':
|
||
case 'Overdue Call':
|
||
return 'phone';
|
||
case 'Field Visit':
|
||
return 'map-marker-alt';
|
||
case 'Formal Overdue Letter':
|
||
return 'envelope';
|
||
case 'Recovery Agent Assigned':
|
||
return 'user-tie';
|
||
case 'Demand Notice':
|
||
return 'file-signature';
|
||
case 'Payment Received':
|
||
return 'check-circle';
|
||
}
|
||
}
|
||
|
||
openTimelineModal(): void {
|
||
this.showTimelineModal = true;
|
||
}
|
||
|
||
closeTimelineModal(): void {
|
||
this.showTimelineModal = false;
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
/** '' = All Account Types. One shared filter drives both the "NPA Account" picker above (so
|
||
* it's quicker to find the right account when adding a new entry) and the Recovery Register
|
||
* below — a single control instead of two that could drift out of sync. */
|
||
recoveryProductFilter = '';
|
||
|
||
get recoveryProductOptions(): string[] {
|
||
return [...new Set(this.npaAccounts.map((a) => a.productName))].sort();
|
||
}
|
||
|
||
setRecoveryProductFilter(productName: string): void {
|
||
this.recoveryProductFilter = productName;
|
||
this.currentPage = 1;
|
||
if (!this.recoveryOptions.some((a) => a.accountNo === this.newRecovery.accountNo)) {
|
||
this.newRecovery.accountNo = '';
|
||
}
|
||
}
|
||
|
||
/** NPA accounts selectable in "Record a Recovery", narrowed by the Account Type filter. */
|
||
get recoveryOptions(): LoanAccount[] {
|
||
return this.npaAccounts.filter((a) => !this.recoveryProductFilter || a.productName === this.recoveryProductFilter);
|
||
}
|
||
|
||
/** recoveryOptions, reshaped for the searchable NPA Account picker — matches on account no,
|
||
* customer name and branch, not just the collapsed label text. */
|
||
get recoveryAccountSearchOptions(): SearchSelectOption[] {
|
||
return this.recoveryOptions.map((a) => ({
|
||
value: a.accountNo,
|
||
label: `${a.accountNo} — ${a.customerName} (${this.stageLabel(this.stage(a))})`,
|
||
searchText: `${a.accountNo} ${a.customerName} ${a.productName} ${a.branchName}`
|
||
}));
|
||
}
|
||
|
||
get recoveryRows(): RecoveryRow[] {
|
||
return this.recoveryAccounts
|
||
.filter((a) => !this.recoveryProductFilter || a.productName === this.recoveryProductFilter)
|
||
.flatMap((a) =>
|
||
a.recoveries.map((r) => ({
|
||
accountNo: a.accountNo,
|
||
customerName: a.customerName,
|
||
branchName: a.branchName,
|
||
productName: a.productName,
|
||
npaCategory: this.stageLabel(this.stage(a)),
|
||
npaCategoryClass: this.badgeClass(this.stage(a)),
|
||
date: r.date,
|
||
amount: r.amount,
|
||
mode: r.mode,
|
||
targetRecovery: expectedRecoveryThisMonth(a)
|
||
}))
|
||
)
|
||
.sort((a, b) => b.amount - a.amount);
|
||
}
|
||
|
||
/** The accounts behind recoveryRows, honouring the Account Type filter — used to total the
|
||
* monthly Target once per account rather than once per recovery transaction (an account with
|
||
* two recovery entries must not have its target counted twice). */
|
||
get recoveryFilteredAccounts(): LoanAccount[] {
|
||
return this.recoveryAccounts.filter(
|
||
(a) => !this.recoveryProductFilter || a.productName === this.recoveryProductFilter
|
||
);
|
||
}
|
||
|
||
get recoveryTargetTotal(): number {
|
||
return this.recoveryFilteredAccounts.reduce((s, a) => s + expectedRecoveryThisMonth(a), 0);
|
||
}
|
||
|
||
get recoveryActualTotal(): number {
|
||
return this.recoveryRows.reduce((s, r) => s + r.amount, 0);
|
||
}
|
||
|
||
// -------------------- 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);
|
||
}
|
||
|
||
// -------------------- Undebited Interest Recovery Report --------------------
|
||
get undebitedInterestAccruedTotal(): number {
|
||
return this.npaAccounts.reduce((s, a) => s + this.interestRev(a), 0);
|
||
}
|
||
|
||
get interestRecoveredCashTotal(): number {
|
||
return this.npaAccounts.reduce((s, a) => s + this.interestRecoveredCashOf(a), 0);
|
||
}
|
||
|
||
get undebitedInterestBalanceTotal(): number {
|
||
return this.npaAccounts.reduce((s, a) => s + this.undebitedInterestBalanceOf(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);
|
||
}
|
||
|
||
// -------------------- Recovery Management Dashboard --------------------
|
||
get recoveryTypeSummary(): RepaymentTypeSummaryRow[] {
|
||
const types: RepaymentType[] = [
|
||
'EMI',
|
||
'OVERDRAWN',
|
||
'BULLET'
|
||
];
|
||
return types.map((type) => {
|
||
const accts = this.allAccounts.filter((a) => a.repaymentType === type);
|
||
return {
|
||
type,
|
||
label: REPAYMENT_TYPE_LABELS[type],
|
||
accounts: accts.length,
|
||
overdueCount: accts.filter((a) => this.isTypeOverdue(a)).length,
|
||
expected: accts.reduce((s, a) => s + this.expectedThisMonth(a), 0),
|
||
actual: accts.reduce((s, a) => s + this.actualThisMonth(a), 0)
|
||
};
|
||
});
|
||
}
|
||
|
||
get expectedRecoveryTotal(): number {
|
||
return this.allAccounts.reduce((s, a) => s + this.expectedThisMonth(a), 0);
|
||
}
|
||
|
||
get actualRecoveryTotal(): number {
|
||
return this.allAccounts.reduce((s, a) => s + this.actualThisMonth(a), 0);
|
||
}
|
||
|
||
get recoveryVarianceTotal(): number {
|
||
return this.actualRecoveryTotal - this.expectedRecoveryTotal;
|
||
}
|
||
|
||
get recoveryPctTotal(): number {
|
||
return this.expectedRecoveryTotal ? (this.actualRecoveryTotal / this.expectedRecoveryTotal) * 100 : 0;
|
||
}
|
||
|
||
get loanWiseRecoveryAccounts(): LoanAccount[] {
|
||
return [...this.allAccounts].sort((a, b) => this.recoveryVariance(a) - this.recoveryVariance(b));
|
||
}
|
||
|
||
// -------------------- Day-wise Overdue Report --------------------
|
||
get overdueAgeingBuckets(): AgeingBucketRow[] {
|
||
const buckets: Array<{ label: string; test: (days: number) => boolean }> = [
|
||
{ label: 'Current (0 Days)', test: (d) => d === 0 },
|
||
{ label: '1–30 Days (SMA-0)', test: (d) => d >= 1 && d <= 30 },
|
||
{ label: '31–60 Days (SMA-1)', test: (d) => d >= 31 && d <= 60 },
|
||
{ label: '61–90 Days (SMA-2)', test: (d) => d >= 61 && d <= 90 },
|
||
{ label: '90+ Days (NPA)', test: (d) => d > 90 }
|
||
];
|
||
return buckets.map((b) => {
|
||
const accts = this.allAccounts.filter((a) => b.test(a.overdueDays));
|
||
return {
|
||
label: b.label,
|
||
count: accts.length,
|
||
outstanding: accts.reduce((s, a) => s + a.outstandingBalance, 0),
|
||
overdueAmt: accts.reduce((s, a) => s + a.overdueAmount, 0)
|
||
};
|
||
});
|
||
}
|
||
|
||
get overdueDaysAccounts(): LoanAccount[] {
|
||
return this.allAccounts.filter((a) => a.overdueDays > 0).sort((a, b) => b.overdueDays - a.overdueDays);
|
||
}
|
||
|
||
// -------------------- Day-wise Overdrawn Report --------------------
|
||
get overdrawnAccounts(): LoanAccount[] {
|
||
return this.allAccounts
|
||
.filter((a) => a.repaymentType === 'OVERDRAWN')
|
||
.sort((a, b) => this.excessOverDp(b) - this.excessOverDp(a));
|
||
}
|
||
|
||
get overdrawnBreachCount(): number {
|
||
return this.overdrawnAccounts.filter((a) => this.overdrawnBreachOf(a)).length;
|
||
}
|
||
|
||
// -------------------- Agriculture / Gold Market Value Report --------------------
|
||
get agriGoldAccounts(): LoanAccount[] {
|
||
return this.allAccounts
|
||
.filter((a) => a.productCode === '05' || a.productCode === '06')
|
||
.sort((a, b) => this.coverageRatio(a) - this.coverageRatio(b));
|
||
}
|
||
|
||
// -------------------- Exception Check Report --------------------
|
||
get exceptionAccounts(): LoanAccount[] {
|
||
return this.allAccounts
|
||
.filter((a) => this.undersecuredOf(a))
|
||
.sort((a, b) => this.securityShortfall(b) - this.securityShortfall(a));
|
||
}
|
||
|
||
// -------------------- NPA Summary Report: stage-wise + branch-wise cuts --------------------
|
||
/** SMA-0/1/2 are early-warning "special mention" buckets, not NPA stages — this report only
|
||
* shows Standard vs the actual NPA classification ladder (Sub-Standard through Loss). SMA
|
||
* accounts still have their own dedicated Day-wise Overdue report. */
|
||
get stageSummary(): StageSummaryRow[] {
|
||
return STAGE_ORDER.filter((stage) => !isSmaStage(stage)).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.stageSummary.reduce((s, row) => s + row.count, 0);
|
||
}
|
||
|
||
get grandTotalOutstanding(): number {
|
||
return this.stageSummary.reduce((s, row) => s + row.outstanding, 0);
|
||
}
|
||
|
||
get grandTotalProvision(): number {
|
||
return this.stageSummary.reduce((s, row) => s + row.provisionReq, 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 {
|
||
this.showTimelineModal = false;
|
||
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}.`);
|
||
}
|
||
|
||
// -------------------- Export to Excel (whichever report is currently on screen) --------------------
|
||
async exportExcel(): Promise<void> {
|
||
this.generating = true;
|
||
try {
|
||
const sheets = this.buildExportSheets();
|
||
await exportWorkbook(sheets, `${this.reportLabel(this.activeReport)}-${this.scopeName}-${this.asOnDate}`);
|
||
} catch (err) {
|
||
console.error('NPA export failed', err);
|
||
} finally {
|
||
this.generating = false;
|
||
}
|
||
}
|
||
|
||
/** Builds the workbook for whichever report is active — every sheet is built from the same
|
||
* derived getters the on-screen tables use, so the export can never disagree with the screen. */
|
||
private buildExportSheets(): ExportSheetSpec[] {
|
||
const subtitle = `Scope: ${this.scopeName} · As On: ${this.asOnDate}`;
|
||
|
||
switch (this.activeReport) {
|
||
case 'interest':
|
||
return [
|
||
{
|
||
sheetName: 'NPA Report',
|
||
title: 'NPA Report — Income Reversed on Non-Performing Accounts',
|
||
subtitle,
|
||
headers: [
|
||
'Account No',
|
||
'Customer',
|
||
'Branch',
|
||
'Product',
|
||
'Stage',
|
||
'Outstanding',
|
||
'Rate %',
|
||
'Overdue Days',
|
||
'Interest Reversed'
|
||
],
|
||
widths: [
|
||
16,
|
||
22,
|
||
16,
|
||
16,
|
||
14,
|
||
16,
|
||
10,
|
||
12,
|
||
18
|
||
],
|
||
rows: [
|
||
...this.npaAccounts.map((a) => [
|
||
a.accountNo,
|
||
a.customerName,
|
||
a.branchName,
|
||
a.productName,
|
||
this.stageLabel(this.stage(a)),
|
||
a.outstandingBalance,
|
||
a.interestRate,
|
||
a.overdueDays,
|
||
this.interestRev(a)
|
||
]),
|
||
[
|
||
`Total (${this.npaAccounts.length} accounts)`,
|
||
'',
|
||
'',
|
||
'',
|
||
'',
|
||
'',
|
||
'',
|
||
'',
|
||
this.sumInterest(this.npaAccounts)
|
||
]
|
||
]
|
||
}
|
||
];
|
||
|
||
case 'status':
|
||
return [
|
||
{
|
||
sheetName: 'NPA Status',
|
||
title: 'NPA Status Report — Full Non-Performing Book',
|
||
subtitle,
|
||
headers: [
|
||
'Account No',
|
||
'Customer',
|
||
'Branch',
|
||
'Product',
|
||
'Secured',
|
||
'Outstanding',
|
||
'Overdue Amount',
|
||
'Overdue Days',
|
||
'NPA Since',
|
||
'Stage',
|
||
'Restructured',
|
||
'Legal Action',
|
||
'Provision Required'
|
||
],
|
||
widths: [
|
||
16,
|
||
22,
|
||
16,
|
||
16,
|
||
10,
|
||
16,
|
||
16,
|
||
12,
|
||
12,
|
||
14,
|
||
12,
|
||
22,
|
||
18
|
||
],
|
||
rows: this.npaAccounts.map((a) => [
|
||
a.accountNo,
|
||
a.customerName,
|
||
a.branchName,
|
||
a.productName,
|
||
a.secured ? 'Yes' : 'No',
|
||
a.outstandingBalance,
|
||
a.overdueAmount,
|
||
a.overdueDays,
|
||
this.npaSince(a),
|
||
this.stageLabel(this.stage(a)),
|
||
a.isRestructured ? 'Yes' : 'No',
|
||
this.legalActionLabel(a.legalActionStatus),
|
||
this.provisionReq(a)
|
||
])
|
||
}
|
||
];
|
||
|
||
case 'recovery':
|
||
return [
|
||
{
|
||
sheetName: 'NPA Recovery',
|
||
title: 'NPA Recovery Register',
|
||
subtitle: this.recoveryProductFilter
|
||
? `${subtitle} · Account Type: ${this.recoveryProductFilter}`
|
||
: subtitle,
|
||
headers: [
|
||
'Date',
|
||
'Account Type',
|
||
'NPA Category',
|
||
'Account No',
|
||
'Customer',
|
||
'Branch',
|
||
'Mode',
|
||
'Target Recovery',
|
||
'Recovered Amount (Actual)'
|
||
],
|
||
widths: [
|
||
12,
|
||
18,
|
||
14,
|
||
16,
|
||
22,
|
||
16,
|
||
18,
|
||
16,
|
||
20
|
||
],
|
||
rows: [
|
||
...this.recoveryRows.map((r) => [
|
||
r.date,
|
||
r.productName,
|
||
r.npaCategory,
|
||
r.accountNo,
|
||
r.customerName,
|
||
r.branchName,
|
||
r.mode,
|
||
r.targetRecovery,
|
||
r.amount
|
||
]),
|
||
[
|
||
'Total (Target once per account · Actual across all entries)',
|
||
'',
|
||
'',
|
||
'',
|
||
'',
|
||
'',
|
||
'',
|
||
this.recoveryTargetTotal,
|
||
this.recoveryActualTotal
|
||
]
|
||
]
|
||
}
|
||
];
|
||
|
||
case 'summary': {
|
||
const sheets: ExportSheetSpec[] = [
|
||
{
|
||
sheetName: 'Stage Summary',
|
||
title: 'NPA Summary Report — Stage-wise',
|
||
subtitle,
|
||
headers: [
|
||
'Classification',
|
||
'Accounts',
|
||
'Outstanding',
|
||
'Provision Required'
|
||
],
|
||
widths: [
|
||
22,
|
||
12,
|
||
18,
|
||
18
|
||
],
|
||
rows: [
|
||
...this.stageSummary.map((row) => [
|
||
row.label,
|
||
row.count,
|
||
row.outstanding,
|
||
row.provisionReq
|
||
]),
|
||
[
|
||
'Total',
|
||
this.grandTotalAccounts,
|
||
this.grandTotalOutstanding,
|
||
this.grandTotalProvision
|
||
]
|
||
]
|
||
}
|
||
];
|
||
if (this.isHeadOffice && !this.branchFilter) {
|
||
sheets.push({
|
||
sheetName: 'Branch Summary',
|
||
title: 'Branch-wise NPA Summary — All Branches',
|
||
subtitle: `As On: ${this.asOnDate}`,
|
||
headers: [
|
||
'Branch',
|
||
'Total Accounts',
|
||
'Total Advances',
|
||
'NPA Accounts',
|
||
'GNPA Outstanding',
|
||
'Provision Required',
|
||
'GNPA %'
|
||
],
|
||
widths: [
|
||
20,
|
||
14,
|
||
18,
|
||
14,
|
||
18,
|
||
18,
|
||
10
|
||
],
|
||
rows: this.branchSummary.map((row) => [
|
||
row.branchName,
|
||
row.totalAccounts,
|
||
row.totalAdvances,
|
||
row.npaCount,
|
||
row.npaOutstanding,
|
||
row.provisionReq,
|
||
Number(row.gnpaRatio.toFixed(2))
|
||
])
|
||
});
|
||
}
|
||
return sheets;
|
||
}
|
||
|
||
case 'account-detail': {
|
||
const a = this.selectedAccount;
|
||
const detailRows: (string | number | null)[][] = a
|
||
? [
|
||
[
|
||
'Account No',
|
||
a.accountNo
|
||
],
|
||
[
|
||
'Customer',
|
||
a.customerName
|
||
],
|
||
[
|
||
'Branch',
|
||
a.branchName
|
||
],
|
||
[
|
||
'Product',
|
||
a.productName
|
||
],
|
||
[
|
||
'Secured',
|
||
a.secured ? 'Yes' : 'No'
|
||
],
|
||
[
|
||
'Guarantor',
|
||
a.guarantorName
|
||
],
|
||
[
|
||
'Sanctioned Amount',
|
||
a.sanctionedAmount
|
||
],
|
||
[
|
||
'Disbursement Date',
|
||
a.disbursementDate
|
||
],
|
||
[
|
||
'Interest Rate %',
|
||
a.interestRate
|
||
],
|
||
[
|
||
'Outstanding Balance',
|
||
a.outstandingBalance
|
||
],
|
||
[
|
||
'Overdue Amount',
|
||
a.overdueAmount
|
||
],
|
||
[
|
||
'Overdue Days',
|
||
a.overdueDays
|
||
],
|
||
[
|
||
'NPA Since',
|
||
this.npaSince(a)
|
||
],
|
||
[
|
||
'Restructured',
|
||
a.isRestructured ? 'Yes' : 'No'
|
||
],
|
||
[
|
||
'Last Payment Date',
|
||
a.lastPaymentDate
|
||
],
|
||
[
|
||
'Collateral Value',
|
||
a.collateralValue
|
||
],
|
||
[
|
||
'Stage',
|
||
this.stageLabel(this.stage(a))
|
||
],
|
||
[
|
||
'Provisioning Rate %',
|
||
this.provisionRate(a)
|
||
],
|
||
[
|
||
'Provision Required',
|
||
this.provisionReq(a)
|
||
],
|
||
[
|
||
'Provision Held',
|
||
this.provisionHeldAmt(a)
|
||
],
|
||
[
|
||
'Interest Reversed',
|
||
this.interestRev(a)
|
||
],
|
||
[
|
||
'Total Recovered',
|
||
this.recovered(a)
|
||
],
|
||
[
|
||
'Movement (vs Last Quarter)',
|
||
this.movement(a)
|
||
],
|
||
[
|
||
'NPA Reason',
|
||
a.npaReason
|
||
],
|
||
[
|
||
'Recovery Officer',
|
||
a.recoveryOfficer
|
||
],
|
||
[
|
||
'Legal Action Status',
|
||
this.legalActionLabel(a.legalActionStatus)
|
||
]
|
||
]
|
||
: [];
|
||
return [
|
||
{
|
||
sheetName: 'Account Detail',
|
||
title: a ? `${a.accountNo} — ${a.customerName}` : 'NPA Account Detail Report',
|
||
subtitle,
|
||
headers: [
|
||
'Field',
|
||
'Value'
|
||
],
|
||
widths: [
|
||
26,
|
||
32
|
||
],
|
||
rows: detailRows
|
||
},
|
||
{
|
||
sheetName: 'Recovery History',
|
||
title: a ? `Recovery History — ${a.accountNo}` : 'Recovery History',
|
||
subtitle,
|
||
headers: [
|
||
'Date',
|
||
'Mode',
|
||
'Amount'
|
||
],
|
||
widths: [
|
||
14,
|
||
20,
|
||
18
|
||
],
|
||
rows: (a?.recoveries || []).map((r) => [
|
||
r.date,
|
||
r.mode,
|
||
r.amount
|
||
])
|
||
},
|
||
{
|
||
sheetName: 'Collection Timeline',
|
||
title: a ? `Recovery / Collection Journey — ${a.accountNo}` : 'Recovery / Collection Journey',
|
||
subtitle,
|
||
headers: [
|
||
'Date',
|
||
'Event',
|
||
'Channel',
|
||
'Remarks'
|
||
],
|
||
widths: [
|
||
14,
|
||
20,
|
||
24,
|
||
46
|
||
],
|
||
rows: (a?.collectionTimeline || []).map((e) => [
|
||
e.date,
|
||
e.type,
|
||
e.channel,
|
||
e.remarks
|
||
])
|
||
}
|
||
];
|
||
}
|
||
|
||
case 'movement':
|
||
return [
|
||
{
|
||
sheetName: 'NPA Movement',
|
||
title: 'Classification Movement — Current Quarter vs Previous Quarter',
|
||
subtitle,
|
||
headers: [
|
||
'Account No',
|
||
'Customer',
|
||
'Branch',
|
||
'Previous Stage',
|
||
'Current Stage',
|
||
'Movement',
|
||
'Outstanding'
|
||
],
|
||
widths: [
|
||
16,
|
||
22,
|
||
16,
|
||
16,
|
||
16,
|
||
26,
|
||
16
|
||
],
|
||
rows: this.movedAccounts.map((a) => [
|
||
a.accountNo,
|
||
a.customerName,
|
||
a.branchName,
|
||
this.stageLabel(this.prevStage(a)),
|
||
this.stageLabel(this.stage(a)),
|
||
this.movement(a),
|
||
a.outstandingBalance
|
||
])
|
||
}
|
||
];
|
||
|
||
case 'provision':
|
||
return [
|
||
{
|
||
sheetName: 'NPA Provision',
|
||
title: 'Provision Held vs Required — Full Loan Book',
|
||
subtitle,
|
||
headers: [
|
||
'Account No',
|
||
'Customer',
|
||
'Branch',
|
||
'Stage',
|
||
'Outstanding',
|
||
'Rate %',
|
||
'Provision Required',
|
||
'Provision Held',
|
||
'Shortfall / (Excess)'
|
||
],
|
||
widths: [
|
||
16,
|
||
22,
|
||
16,
|
||
14,
|
||
16,
|
||
10,
|
||
18,
|
||
16,
|
||
18
|
||
],
|
||
rows: [
|
||
...this.provisionRows.map((a) => [
|
||
a.accountNo,
|
||
a.customerName,
|
||
a.branchName,
|
||
this.stageLabel(this.stage(a)),
|
||
a.outstandingBalance,
|
||
this.provisionRate(a),
|
||
this.provisionReq(a),
|
||
this.provisionHeldAmt(a),
|
||
this.shortfall(a)
|
||
]),
|
||
[
|
||
`Total (${this.provisionRows.length} accounts)`,
|
||
'',
|
||
'',
|
||
'',
|
||
'',
|
||
'',
|
||
this.provisionRequiredTotal,
|
||
this.provisionHeldTotal,
|
||
this.provisionShortfallTotal
|
||
]
|
||
]
|
||
}
|
||
];
|
||
|
||
case 'undebited-interest':
|
||
return [
|
||
{
|
||
sheetName: 'Undebited Interest',
|
||
title: 'Undebited Interest Recovery Report — Memorandum Interest (IRAC Norms)',
|
||
subtitle,
|
||
headers: [
|
||
'Account No',
|
||
'Customer',
|
||
'Branch',
|
||
'Stage',
|
||
'NPA Since',
|
||
'Days NPA',
|
||
'Rate %',
|
||
'Undebited Interest Accrued',
|
||
'Interest Recovered (Cash)',
|
||
'Undebited Interest Balance'
|
||
],
|
||
widths: [
|
||
16,
|
||
22,
|
||
16,
|
||
14,
|
||
12,
|
||
10,
|
||
10,
|
||
22,
|
||
20,
|
||
22
|
||
],
|
||
rows: [
|
||
...this.npaAccounts.map((a) => [
|
||
a.accountNo,
|
||
a.customerName,
|
||
a.branchName,
|
||
this.stageLabel(this.stage(a)),
|
||
this.npaSince(a),
|
||
this.daysSinceNpaOf(a),
|
||
a.interestRate,
|
||
this.interestRev(a),
|
||
this.interestRecoveredCashOf(a),
|
||
this.undebitedInterestBalanceOf(a)
|
||
]),
|
||
[
|
||
`Total (${this.npaAccounts.length} accounts)`,
|
||
'',
|
||
'',
|
||
'',
|
||
'',
|
||
'',
|
||
'',
|
||
this.undebitedInterestAccruedTotal,
|
||
this.interestRecoveredCashTotal,
|
||
this.undebitedInterestBalanceTotal
|
||
]
|
||
]
|
||
}
|
||
];
|
||
|
||
case 'probable':
|
||
return [
|
||
{
|
||
sheetName: 'Probable NPA',
|
||
title: 'Probable NPA — SMA-2 Accounts Likely to Slip Next Quarter',
|
||
subtitle,
|
||
headers: [
|
||
'Account No',
|
||
'Customer',
|
||
'Branch',
|
||
'Product',
|
||
'Outstanding',
|
||
'Overdue Amount',
|
||
'Overdue Days',
|
||
'Last Payment',
|
||
'Follow-up'
|
||
],
|
||
widths: [
|
||
16,
|
||
22,
|
||
16,
|
||
16,
|
||
16,
|
||
16,
|
||
12,
|
||
14,
|
||
12
|
||
],
|
||
rows: [
|
||
...this.probableNpaAccounts.map((a) => [
|
||
a.accountNo,
|
||
a.customerName,
|
||
a.branchName,
|
||
a.productName,
|
||
a.outstandingBalance,
|
||
a.overdueAmount,
|
||
a.overdueDays,
|
||
a.lastPaymentDate,
|
||
a.overdueDays > 75 ? 'Urgent' : 'Monitor'
|
||
]),
|
||
[
|
||
`Total (${this.probableNpaAccounts.length} accounts)`,
|
||
'',
|
||
'',
|
||
'',
|
||
this.sumOutstanding(this.probableNpaAccounts),
|
||
'',
|
||
'',
|
||
'',
|
||
''
|
||
]
|
||
]
|
||
}
|
||
];
|
||
|
||
case 'sma':
|
||
return [
|
||
{
|
||
sheetName: 'SMA Report',
|
||
title: 'Special Mention Accounts — SMA-0 / SMA-1 / SMA-2',
|
||
subtitle,
|
||
headers: [
|
||
'Account No',
|
||
'Customer',
|
||
'Branch',
|
||
'Product',
|
||
'Stage',
|
||
'Outstanding',
|
||
'Overdue Amount',
|
||
'Overdue Days'
|
||
],
|
||
widths: [
|
||
16,
|
||
22,
|
||
16,
|
||
16,
|
||
14,
|
||
16,
|
||
16,
|
||
12
|
||
],
|
||
rows: [
|
||
...this.smaAccounts.map((a) => [
|
||
a.accountNo,
|
||
a.customerName,
|
||
a.branchName,
|
||
a.productName,
|
||
this.stageLabel(this.stage(a)),
|
||
a.outstandingBalance,
|
||
a.overdueAmount,
|
||
a.overdueDays
|
||
]),
|
||
[
|
||
`Total (${this.smaAccounts.length} accounts)`,
|
||
'',
|
||
'',
|
||
'',
|
||
'',
|
||
this.sumOutstanding(this.smaAccounts),
|
||
this.sumOverdue(this.smaAccounts),
|
||
''
|
||
]
|
||
]
|
||
}
|
||
];
|
||
|
||
case 'recovery-dashboard':
|
||
return [
|
||
{
|
||
sheetName: 'Recovery Summary',
|
||
title: 'Expected vs Actual Recovery — This Cycle, by Loan Type',
|
||
subtitle,
|
||
headers: [
|
||
'Loan Type',
|
||
'Accounts',
|
||
'Overdue Now',
|
||
'Expected Recovery',
|
||
'Actual Recovery',
|
||
'Variance'
|
||
],
|
||
widths: [
|
||
20,
|
||
12,
|
||
12,
|
||
18,
|
||
18,
|
||
16
|
||
],
|
||
rows: [
|
||
...this.recoveryTypeSummary.map((row) => [
|
||
row.label,
|
||
row.accounts,
|
||
row.overdueCount,
|
||
row.expected,
|
||
row.actual,
|
||
row.actual - row.expected
|
||
]),
|
||
[
|
||
'Total',
|
||
this.allAccounts.length,
|
||
this.recoveryTypeSummary.reduce((s, row) => s + row.overdueCount, 0),
|
||
this.expectedRecoveryTotal,
|
||
this.actualRecoveryTotal,
|
||
this.recoveryVarianceTotal
|
||
]
|
||
]
|
||
},
|
||
{
|
||
sheetName: 'Loan-wise Recovery',
|
||
title: 'Loan-wise Expected vs Actual Recovery',
|
||
subtitle,
|
||
headers: [
|
||
'Account No',
|
||
'Customer',
|
||
'Branch',
|
||
'Loan Type',
|
||
'Expected',
|
||
'Actual',
|
||
'Variance',
|
||
'Status'
|
||
],
|
||
widths: [
|
||
16,
|
||
22,
|
||
16,
|
||
18,
|
||
16,
|
||
16,
|
||
16,
|
||
12
|
||
],
|
||
rows: this.loanWiseRecoveryAccounts.map((a) => [
|
||
a.accountNo,
|
||
a.customerName,
|
||
a.branchName,
|
||
this.repaymentTypeLabel(a.repaymentType),
|
||
this.expectedThisMonth(a),
|
||
this.actualThisMonth(a),
|
||
this.recoveryVariance(a),
|
||
this.recoveryVariance(a) >= 0 ? 'Recovered' : 'Shortfall'
|
||
])
|
||
}
|
||
];
|
||
|
||
case 'daywise-overdue':
|
||
return [
|
||
{
|
||
sheetName: 'Ageing Buckets',
|
||
title: 'Day-wise Overdue Report — Ageing Buckets',
|
||
subtitle,
|
||
headers: [
|
||
'Overdue Bucket',
|
||
'Accounts',
|
||
'Outstanding',
|
||
'Overdue Amount'
|
||
],
|
||
widths: [
|
||
22,
|
||
12,
|
||
18,
|
||
18
|
||
],
|
||
rows: this.overdueAgeingBuckets.map((b) => [
|
||
b.label,
|
||
b.count,
|
||
b.outstanding,
|
||
b.overdueAmt
|
||
])
|
||
},
|
||
{
|
||
sheetName: 'Overdue Accounts',
|
||
title: 'Overdue Accounts — Day-wise, Marked by SMA Stage',
|
||
subtitle,
|
||
headers: [
|
||
'Account No',
|
||
'Customer',
|
||
'Branch',
|
||
'Product',
|
||
'Stage',
|
||
'Overdue Days',
|
||
'Outstanding',
|
||
'Overdue Amount'
|
||
],
|
||
widths: [
|
||
16,
|
||
22,
|
||
16,
|
||
16,
|
||
14,
|
||
12,
|
||
16,
|
||
18
|
||
],
|
||
rows: this.overdueDaysAccounts.map((a) => [
|
||
a.accountNo,
|
||
a.customerName,
|
||
a.branchName,
|
||
a.productName,
|
||
this.stageLabel(this.stage(a)),
|
||
a.overdueDays,
|
||
a.outstandingBalance,
|
||
a.overdueAmount
|
||
])
|
||
}
|
||
];
|
||
|
||
case 'daywise-overdrawn':
|
||
return [
|
||
{
|
||
sheetName: 'Overdrawn Accounts',
|
||
title: 'Day-wise Overdrawn Report — Cash Credit / Overdraft / KCC Accounts',
|
||
subtitle,
|
||
headers: [
|
||
'Account No',
|
||
'Customer',
|
||
'Branch',
|
||
'Product',
|
||
'Drawing Power',
|
||
'Outstanding',
|
||
'Excess Over DP',
|
||
'Days Overdrawn',
|
||
'Status'
|
||
],
|
||
widths: [
|
||
16,
|
||
22,
|
||
16,
|
||
16,
|
||
16,
|
||
16,
|
||
16,
|
||
14,
|
||
14
|
||
],
|
||
rows: this.overdrawnAccounts.map((a) => [
|
||
a.accountNo,
|
||
a.customerName,
|
||
a.branchName,
|
||
a.productName,
|
||
a.drawingPower,
|
||
a.outstandingBalance,
|
||
this.excessOverDp(a),
|
||
this.overdrawnBreachOf(a) ? a.overdueDays : 0,
|
||
this.overdrawnBreachOf(a) ? 'DP Breached' : 'Within Limit'
|
||
])
|
||
}
|
||
];
|
||
|
||
case 'agri-gold-value':
|
||
return [
|
||
{
|
||
sheetName: 'Agri-Gold Value',
|
||
title: 'Agriculture / Gold Market Value Report — Current Security Value vs Loan',
|
||
subtitle,
|
||
headers: [
|
||
'Account No',
|
||
'Customer',
|
||
'Branch',
|
||
'Product',
|
||
'Outstanding',
|
||
'Collateral Value (At Sanction)',
|
||
'Market Value (Now)',
|
||
'Coverage %',
|
||
'Status'
|
||
],
|
||
widths: [
|
||
16,
|
||
22,
|
||
16,
|
||
16,
|
||
16,
|
||
22,
|
||
18,
|
||
12,
|
||
14
|
||
],
|
||
rows: this.agriGoldAccounts.map((a) => [
|
||
a.accountNo,
|
||
a.customerName,
|
||
a.branchName,
|
||
a.productName,
|
||
a.outstandingBalance,
|
||
a.collateralValue,
|
||
a.collateralMarketValue,
|
||
Number(this.coverageRatio(a).toFixed(0)),
|
||
this.undersecuredOf(a) ? 'Undersecured' : 'Adequate'
|
||
])
|
||
}
|
||
];
|
||
|
||
case 'exception-check':
|
||
return [
|
||
{
|
||
sheetName: 'Exception Check',
|
||
title: 'Exception Check — Security Value vs Loan',
|
||
subtitle,
|
||
headers: [
|
||
'Account No',
|
||
'Customer',
|
||
'Branch',
|
||
'Product',
|
||
'Stage',
|
||
'Outstanding',
|
||
'Market Value',
|
||
'Shortfall',
|
||
'Coverage %'
|
||
],
|
||
widths: [
|
||
16,
|
||
22,
|
||
16,
|
||
16,
|
||
14,
|
||
16,
|
||
16,
|
||
16,
|
||
12
|
||
],
|
||
rows: this.exceptionAccounts.map((a) => [
|
||
a.accountNo,
|
||
a.customerName,
|
||
a.branchName,
|
||
a.productName,
|
||
this.stageLabel(this.stage(a)),
|
||
a.outstandingBalance,
|
||
a.collateralMarketValue,
|
||
this.securityShortfall(a),
|
||
Number(this.coverageRatio(a).toFixed(0))
|
||
])
|
||
}
|
||
];
|
||
|
||
default:
|
||
return [];
|
||
}
|
||
}
|
||
|
||
private toast(msg: string): void {
|
||
this.toastMsg = msg;
|
||
this.toastVisible = true;
|
||
if (this.toastTimer) {
|
||
clearTimeout(this.toastTimer);
|
||
}
|
||
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
|
||
}
|
||
}
|