- Correct Investment portfolio allocation and Loan Origination product-mix targets against RBI's UCB SLR/Non-SLR framework and PSL circulars, with a Target Basis column distinguishing RBI-mandated vs board-policy rows (Excel export included). - Expand Investment holdings dataset with more near-term maturities so the Due-Date Diary reflects a realistic spread across FD/Govt Security/Non-SLR/Bond. - Add From/To period-based Interest Calculation & Post (with GL selection for both posting and receiving) across the Investment Interest module, FD Entry and Bond Entry screens; fix a bug where a posted period kept re-appearing as due since accrual wasn't tracked per-holding. - Add a reusable DD-MM-YYYY date-picker component and roll it out across all Investment date fields. - NPA Summary Report: drop SMA-0/1/2 from the stage-wise table (kept in Day-wise Overdue only). - NPA Recovery Report: add Account Type / NPA Category / Target-vs-Actual columns, a shared Account Type filter driving both the account picker and the register, and a searchable (fixed-position, not clipped by card overflow) account select for long lists. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
685 lines
20 KiB
TypeScript
685 lines
20 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, OnInit, inject } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
|
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
|
import { ExportSheetSpec, exportWorkbook } from '../../cashier/cashier-export';
|
|
import {
|
|
BRANCHES,
|
|
BranchMaster,
|
|
branchTargetAmount,
|
|
Channel,
|
|
CHANNEL_LABELS,
|
|
CHANNEL_TARGET_PCT,
|
|
CustomerType,
|
|
LoanApplication,
|
|
LOAN_APPLICATIONS,
|
|
ORIGINATION_PRODUCTS,
|
|
OriginationStage,
|
|
OverallStatus,
|
|
PRODUCT_TARGET_PCT,
|
|
STAGE_LABELS,
|
|
STAGE_ORDER,
|
|
STATUS_LABELS,
|
|
TARGET_STATUS_LABELS,
|
|
TargetStatus,
|
|
createApplication,
|
|
progressPct,
|
|
stageBadgeClass,
|
|
targetStatus,
|
|
todayDDMMYYYY
|
|
} from '../loan-origination-data';
|
|
|
|
interface ChannelReportRow {
|
|
channel: Channel;
|
|
label: string;
|
|
count: number;
|
|
requestedAmount: number;
|
|
disbursedCount: number;
|
|
disbursedAmount: number;
|
|
}
|
|
|
|
interface BcReportRow {
|
|
bcAgentName: string;
|
|
count: number;
|
|
requestedAmount: number;
|
|
disbursedCount: number;
|
|
disbursedAmount: number;
|
|
}
|
|
|
|
interface BranchTargetRow {
|
|
branchCode: string;
|
|
branchName: string;
|
|
target: number;
|
|
actual: number;
|
|
achievementPct: number;
|
|
status: TargetStatus;
|
|
}
|
|
|
|
interface MixTargetRow {
|
|
code: string;
|
|
label: string;
|
|
targetPct: number;
|
|
actualPct: number;
|
|
variancePct: number;
|
|
status: TargetStatus;
|
|
/** True only for the two rows whose target is an actual RBI PSL sub-target (Agriculture 18% /
|
|
* Micro Enterprises 7.5% of ANBC) rather than an internal business-plan assumption. */
|
|
isRbiMandated: boolean;
|
|
}
|
|
|
|
type ViewId = 'pipeline' | 'new' | 'channel' | 'bc' | 'share' | 'target';
|
|
|
|
/**
|
|
* Loan Origination pipeline — every application currently moving through the sanctioning
|
|
* workflow (Application → KYC → CIC Check → Field Investigation → Personal Discussion → Credit
|
|
* Committee → Risk Approval → Legal Approval → Customer Signing → Cheque/ACH → Post-Check →
|
|
* Disbursed), plus the Channel-wise / BC-wise / Share Member reports and a New Application
|
|
* intake form. Every count/total here is derived from the same LOAN_APPLICATIONS array the
|
|
* Application Detail screen mutates, so the pipeline view can never drift from an application's
|
|
* real state.
|
|
*/
|
|
@Component({
|
|
selector: 'mifosx-loan-pipeline',
|
|
templateUrl: './loan-pipeline.component.html',
|
|
styleUrls: [
|
|
'../../cashier/cashier-shared.scss',
|
|
'../../investment/investment-shared.scss',
|
|
'../../npa/npa-shared.scss',
|
|
'../loan-origination-shared.scss'
|
|
],
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
FormsModule,
|
|
RouterModule
|
|
]
|
|
})
|
|
export class LoanPipelineComponent implements OnInit {
|
|
readonly stageOrder = STAGE_ORDER;
|
|
readonly stageLabels = STAGE_LABELS;
|
|
readonly statusLabels = STATUS_LABELS;
|
|
readonly channelLabels = CHANNEL_LABELS;
|
|
readonly branches = BRANCHES;
|
|
readonly products = ORIGINATION_PRODUCTS;
|
|
asOnDate = todayDDMMYYYY();
|
|
|
|
activeView: ViewId = 'pipeline';
|
|
|
|
isHeadOffice = false;
|
|
currentOfficeName = 'Branch Office';
|
|
private ownBranchCode: string | null = null;
|
|
branchFilter = '';
|
|
|
|
channelFilter = '';
|
|
stageFilter = '';
|
|
statusFilter = '';
|
|
searchText = '';
|
|
|
|
readonly pageSize = 20;
|
|
currentPage = 1;
|
|
|
|
generating = false;
|
|
|
|
newApp = {
|
|
channel: 'BRANCH' as Channel,
|
|
bcAgentName: '',
|
|
customerType: 'NEW' as CustomerType,
|
|
applicantName: '',
|
|
branchCode: this.branches[0].code,
|
|
productCode: this.products[0].code,
|
|
requestedAmount: null as number | null,
|
|
tenureMonths: null as number | null
|
|
};
|
|
|
|
toastMsg = '';
|
|
toastVisible = false;
|
|
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
private readonly route = inject(ActivatedRoute);
|
|
private readonly router = inject(Router);
|
|
private readonly auth = inject(AuthenticationService);
|
|
|
|
ngOnInit(): void {
|
|
const requestedView = this.route.snapshot.queryParamMap.get('view') as ViewId | null;
|
|
if (requestedView && [
|
|
'pipeline',
|
|
'new',
|
|
'channel',
|
|
'bc',
|
|
'share',
|
|
'target'
|
|
].includes(requestedView)) {
|
|
this.activeView = requestedView;
|
|
}
|
|
|
|
const creds = this.auth.getCredentials();
|
|
this.isHeadOffice = this.auth.isHeadOfficeUser();
|
|
this.currentOfficeName = creds?.officeName || 'Branch Office';
|
|
if (this.isHeadOffice) {
|
|
this.branchFilter = '';
|
|
} else {
|
|
this.ownBranchCode =
|
|
this.branches.find((b: BranchMaster) => this.currentOfficeName.toLowerCase().includes(b.name.toLowerCase()))
|
|
?.code || null;
|
|
this.branchFilter = this.ownBranchCode || this.branches[0].code;
|
|
// A branch-level login can only originate applications for its own branch — locked here,
|
|
// not just hidden, since the New Application form posts whatever branchCode is bound.
|
|
this.newApp.branchCode = this.branchFilter;
|
|
}
|
|
}
|
|
|
|
/** Branches selectable on the New Application form — every branch for Head Office, only the
|
|
* logged-in user's own branch otherwise. */
|
|
get newApplicationBranches(): BranchMaster[] {
|
|
return this.isHeadOffice ? this.branches : this.branches.filter((b) => b.code === this.branchFilter);
|
|
}
|
|
|
|
setView(view: ViewId): void {
|
|
this.activeView = view;
|
|
this.currentPage = 1;
|
|
}
|
|
|
|
setBranch(code: string): void {
|
|
this.branchFilter = code;
|
|
this.currentPage = 1;
|
|
}
|
|
|
|
/** Name of the branch/scope currently being viewed, for display in the header. */
|
|
get scopeName(): string {
|
|
if (!this.isHeadOffice) {
|
|
return this.currentOfficeName;
|
|
}
|
|
return this.branchFilter
|
|
? this.branches.find((b) => b.code === this.branchFilter)?.name || ''
|
|
: 'All Branches (Consolidated)';
|
|
}
|
|
|
|
get scopedApplications(): LoanApplication[] {
|
|
return this.branchFilter ? LOAN_APPLICATIONS.filter((a) => a.branchCode === this.branchFilter) : LOAN_APPLICATIONS;
|
|
}
|
|
|
|
get filteredApplications(): LoanApplication[] {
|
|
return this.scopedApplications.filter((a) => {
|
|
if (this.channelFilter && a.channel !== this.channelFilter) {
|
|
return false;
|
|
}
|
|
if (this.stageFilter && a.currentStage !== this.stageFilter) {
|
|
return false;
|
|
}
|
|
if (this.statusFilter && a.overallStatus !== this.statusFilter) {
|
|
return false;
|
|
}
|
|
if (this.searchText) {
|
|
const q = this.searchText.toLowerCase();
|
|
return a.applicationNo.toLowerCase().includes(q) || a.applicantName.toLowerCase().includes(q);
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
// -------------------- Pagination --------------------
|
|
paged<T>(items: T[]): T[] {
|
|
const start = (this.currentPage - 1) * this.pageSize;
|
|
return items.slice(start, start + this.pageSize);
|
|
}
|
|
|
|
pageCount(items: unknown[]): number {
|
|
return Math.max(1, Math.ceil(items.length / this.pageSize));
|
|
}
|
|
|
|
rangeStart(items: unknown[]): number {
|
|
return items.length === 0 ? 0 : (this.currentPage - 1) * this.pageSize + 1;
|
|
}
|
|
|
|
rangeEnd(items: unknown[]): number {
|
|
return Math.min(this.currentPage * this.pageSize, items.length);
|
|
}
|
|
|
|
goToPage(page: number, items: unknown[]): void {
|
|
this.currentPage = Math.min(Math.max(1, page), this.pageCount(items));
|
|
}
|
|
|
|
// -------------------- Portfolio KPIs --------------------
|
|
get totalApplications(): number {
|
|
return this.scopedApplications.length;
|
|
}
|
|
|
|
get inProgressCount(): number {
|
|
return this.scopedApplications.filter((a) => a.overallStatus === 'IN_PROGRESS').length;
|
|
}
|
|
|
|
get onHoldCount(): number {
|
|
return this.scopedApplications.filter((a) => a.overallStatus === 'ON_HOLD').length;
|
|
}
|
|
|
|
get rejectedCount(): number {
|
|
return this.scopedApplications.filter((a) => a.overallStatus === 'REJECTED').length;
|
|
}
|
|
|
|
get disbursedApplications(): LoanApplication[] {
|
|
return this.scopedApplications.filter((a) => a.overallStatus === 'DISBURSED');
|
|
}
|
|
|
|
get disbursedAmount(): number {
|
|
return this.disbursedApplications.reduce((s, a) => s + a.requestedAmount, 0);
|
|
}
|
|
|
|
progress(a: LoanApplication): number {
|
|
return progressPct(a);
|
|
}
|
|
|
|
badgeClass(a: LoanApplication): string {
|
|
return stageBadgeClass(a);
|
|
}
|
|
|
|
statusLabel(status: OverallStatus): string {
|
|
return STATUS_LABELS[status];
|
|
}
|
|
|
|
stageLabel(stage: OriginationStage): string {
|
|
return STAGE_LABELS[stage];
|
|
}
|
|
|
|
openApplication(a: LoanApplication): void {
|
|
this.router.navigate([
|
|
'/loan-origination/application',
|
|
a.applicationNo
|
|
]);
|
|
}
|
|
|
|
// -------------------- Channel-wise / BC-wise / Share Member reports --------------------
|
|
get channelReport(): ChannelReportRow[] {
|
|
const channels: Channel[] = [
|
|
'BRANCH',
|
|
'BC',
|
|
'SELF_APPLY'
|
|
];
|
|
return channels.map((channel) => {
|
|
const rows = this.scopedApplications.filter((a) => a.channel === channel);
|
|
const disbursed = rows.filter((a) => a.overallStatus === 'DISBURSED');
|
|
return {
|
|
channel,
|
|
label: CHANNEL_LABELS[channel],
|
|
count: rows.length,
|
|
requestedAmount: rows.reduce((s, a) => s + a.requestedAmount, 0),
|
|
disbursedCount: disbursed.length,
|
|
disbursedAmount: disbursed.reduce((s, a) => s + a.requestedAmount, 0)
|
|
};
|
|
});
|
|
}
|
|
|
|
get bcApplications(): LoanApplication[] {
|
|
return this.scopedApplications.filter((a) => a.channel === 'BC');
|
|
}
|
|
|
|
get bcReport(): BcReportRow[] {
|
|
const names = Array.from(new Set(this.bcApplications.map((a) => a.bcAgentName))).sort();
|
|
return names.map((bcAgentName) => {
|
|
const rows = this.bcApplications.filter((a) => a.bcAgentName === bcAgentName);
|
|
const disbursed = rows.filter((a) => a.overallStatus === 'DISBURSED');
|
|
return {
|
|
bcAgentName,
|
|
count: rows.length,
|
|
requestedAmount: rows.reduce((s, a) => s + a.requestedAmount, 0),
|
|
disbursedCount: disbursed.length,
|
|
disbursedAmount: disbursed.reduce((s, a) => s + a.requestedAmount, 0)
|
|
};
|
|
});
|
|
}
|
|
|
|
get shareMemberCount(): number {
|
|
return this.scopedApplications.filter((a) => a.isShareMember).length;
|
|
}
|
|
|
|
get nonMemberApplications(): LoanApplication[] {
|
|
return this.scopedApplications.filter((a) => !a.isShareMember && a.overallStatus !== 'REJECTED');
|
|
}
|
|
|
|
// -------------------- Disbursement Target vs Actual --------------------
|
|
statusLabelOf(status: TargetStatus): string {
|
|
return TARGET_STATUS_LABELS[status];
|
|
}
|
|
|
|
/** Branches shown in the target table: every branch for Head Office's consolidated view, only
|
|
* the drilled-into/own branch otherwise — same scoping the rest of the screen already uses. */
|
|
get branchTargetRows(): BranchTargetRow[] {
|
|
const branchesToShow =
|
|
this.isHeadOffice && !this.branchFilter
|
|
? this.branches
|
|
: this.branches.filter((b) => b.code === this.branchFilter);
|
|
return branchesToShow.map((b) => {
|
|
const actual = LOAN_APPLICATIONS.filter((a) => a.branchCode === b.code && a.overallStatus === 'DISBURSED').reduce(
|
|
(s, a) => s + a.requestedAmount,
|
|
0
|
|
);
|
|
const target = branchTargetAmount(b.code);
|
|
const achievementPct = target ? (actual / target) * 100 : 0;
|
|
return {
|
|
branchCode: b.code,
|
|
branchName: b.name,
|
|
target,
|
|
actual,
|
|
achievementPct,
|
|
status: targetStatus(achievementPct, 100, 5)
|
|
};
|
|
});
|
|
}
|
|
|
|
private get disbursedInScope(): LoanApplication[] {
|
|
return this.scopedApplications.filter((a) => a.overallStatus === 'DISBURSED');
|
|
}
|
|
|
|
get productTargetRows(): MixTargetRow[] {
|
|
const total = this.disbursedInScope.reduce((s, a) => s + a.requestedAmount, 0);
|
|
return this.products.map((p) => {
|
|
const productAmount = this.disbursedInScope
|
|
.filter((a) => a.productCode === p.code)
|
|
.reduce((s, a) => s + a.requestedAmount, 0);
|
|
const actualPct = total ? (productAmount / total) * 100 : 0;
|
|
const targetPct = PRODUCT_TARGET_PCT[p.code] || 0;
|
|
return {
|
|
code: p.code,
|
|
label: p.name,
|
|
targetPct,
|
|
actualPct,
|
|
variancePct: actualPct - targetPct,
|
|
status: targetStatus(actualPct, targetPct, 3),
|
|
isRbiMandated: p.code === 'KCC' || p.code === 'CC'
|
|
};
|
|
});
|
|
}
|
|
|
|
get channelTargetRows(): MixTargetRow[] {
|
|
const total = this.disbursedInScope.reduce((s, a) => s + a.requestedAmount, 0);
|
|
const channels: Channel[] = [
|
|
'BRANCH',
|
|
'BC',
|
|
'SELF_APPLY'
|
|
];
|
|
return channels.map((channel) => {
|
|
const channelAmount = this.disbursedInScope
|
|
.filter((a) => a.channel === channel)
|
|
.reduce((s, a) => s + a.requestedAmount, 0);
|
|
const actualPct = total ? (channelAmount / total) * 100 : 0;
|
|
const targetPct = CHANNEL_TARGET_PCT[channel];
|
|
return {
|
|
code: channel,
|
|
label: CHANNEL_LABELS[channel],
|
|
targetPct,
|
|
actualPct,
|
|
variancePct: actualPct - targetPct,
|
|
status: targetStatus(actualPct, targetPct, 3),
|
|
isRbiMandated: false
|
|
};
|
|
});
|
|
}
|
|
|
|
// -------------------- New Application intake --------------------
|
|
productOf(code: string) {
|
|
return this.products.find((p) => p.code === code) || this.products[0];
|
|
}
|
|
|
|
saveNewApplication(): void {
|
|
if (!this.newApp.applicantName.trim() || !this.newApp.requestedAmount || this.newApp.requestedAmount <= 0) {
|
|
this.toast('Enter the applicant name and a valid requested amount.');
|
|
return;
|
|
}
|
|
if (this.newApp.channel === 'BC' && !this.newApp.bcAgentName.trim()) {
|
|
this.toast('Enter the BC agent/point name for a Business Correspondent application.');
|
|
return;
|
|
}
|
|
// A branch-level login is locked to its own branch regardless of what the (disabled) select
|
|
// is bound to — defence in depth against the DOM value ever being tampered with.
|
|
if (!this.isHeadOffice && this.ownBranchCode) {
|
|
this.newApp.branchCode = this.ownBranchCode;
|
|
}
|
|
const branch = this.branches.find((b) => b.code === this.newApp.branchCode) || this.branches[0];
|
|
const product = this.productOf(this.newApp.productCode);
|
|
const seq = LOAN_APPLICATIONS.length + 20000;
|
|
const app = createApplication(
|
|
{
|
|
channel: this.newApp.channel,
|
|
bcAgentName: this.newApp.bcAgentName || '—',
|
|
customerType: this.newApp.customerType,
|
|
applicantName: this.newApp.applicantName.trim(),
|
|
branchCode: branch.code,
|
|
branchName: branch.name,
|
|
productCode: product.code,
|
|
productName: product.name,
|
|
secured: product.secured,
|
|
requestedAmount: this.newApp.requestedAmount,
|
|
tenureMonths: this.newApp.tenureMonths || product.minTenure,
|
|
interestRate: +((product.minRate + product.maxRate) / 2).toFixed(2)
|
|
},
|
|
seq
|
|
);
|
|
LOAN_APPLICATIONS.unshift(app);
|
|
this.toast(`Application ${app.applicationNo} received for ${app.applicantName}.`);
|
|
this.newApp = {
|
|
channel: 'BRANCH',
|
|
bcAgentName: '',
|
|
customerType: 'NEW',
|
|
applicantName: '',
|
|
branchCode: this.isHeadOffice ? this.branches[0].code : this.ownBranchCode || this.branches[0].code,
|
|
productCode: this.products[0].code,
|
|
requestedAmount: null,
|
|
tenureMonths: null
|
|
};
|
|
this.router.navigate([
|
|
'/loan-origination/application',
|
|
app.applicationNo
|
|
]);
|
|
}
|
|
|
|
// -------------------- Export to Excel --------------------
|
|
async exportExcel(): Promise<void> {
|
|
this.generating = true;
|
|
try {
|
|
const subtitle = `Scope: ${this.scopeName} · As On: ${this.asOnDate}`;
|
|
const sheets: ExportSheetSpec[] = [
|
|
{
|
|
sheetName: 'Pipeline',
|
|
title: 'Loan Origination Pipeline',
|
|
subtitle,
|
|
headers: [
|
|
'Application No',
|
|
'Applicant',
|
|
'Branch',
|
|
'Channel',
|
|
'Product',
|
|
'Requested Amount',
|
|
'Current Stage',
|
|
'Status'
|
|
],
|
|
widths: [
|
|
16,
|
|
22,
|
|
16,
|
|
22,
|
|
16,
|
|
18,
|
|
24,
|
|
14
|
|
],
|
|
rows: this.filteredApplications.map((a) => [
|
|
a.applicationNo,
|
|
a.applicantName,
|
|
a.branchName,
|
|
CHANNEL_LABELS[a.channel],
|
|
a.productName,
|
|
a.requestedAmount,
|
|
STAGE_LABELS[a.currentStage],
|
|
STATUS_LABELS[a.overallStatus]
|
|
])
|
|
},
|
|
{
|
|
sheetName: 'Channel-wise',
|
|
title: 'Channel-wise Report',
|
|
subtitle,
|
|
headers: [
|
|
'Channel',
|
|
'Applications',
|
|
'Requested Amount',
|
|
'Disbursed Count',
|
|
'Disbursed Amount'
|
|
],
|
|
widths: [
|
|
26,
|
|
16,
|
|
18,
|
|
16,
|
|
18
|
|
],
|
|
rows: this.channelReport.map((r) => [
|
|
r.label,
|
|
r.count,
|
|
r.requestedAmount,
|
|
r.disbursedCount,
|
|
r.disbursedAmount
|
|
])
|
|
},
|
|
{
|
|
sheetName: 'BC-wise',
|
|
title: 'BC-wise Report',
|
|
subtitle,
|
|
headers: [
|
|
'BC Agent / Point',
|
|
'Applications',
|
|
'Requested Amount',
|
|
'Disbursed Count',
|
|
'Disbursed Amount'
|
|
],
|
|
widths: [
|
|
30,
|
|
16,
|
|
18,
|
|
16,
|
|
18
|
|
],
|
|
rows: this.bcReport.map((r) => [
|
|
r.bcAgentName,
|
|
r.count,
|
|
r.requestedAmount,
|
|
r.disbursedCount,
|
|
r.disbursedAmount
|
|
])
|
|
},
|
|
{
|
|
sheetName: 'Share Member',
|
|
title: 'Share Member Report',
|
|
subtitle,
|
|
headers: [
|
|
'Application No',
|
|
'Applicant',
|
|
'Branch',
|
|
'Current Stage',
|
|
'Status'
|
|
],
|
|
widths: [
|
|
16,
|
|
22,
|
|
16,
|
|
24,
|
|
14
|
|
],
|
|
rows: this.nonMemberApplications.map((a) => [
|
|
a.applicationNo,
|
|
a.applicantName,
|
|
a.branchName,
|
|
STAGE_LABELS[a.currentStage],
|
|
STATUS_LABELS[a.overallStatus]
|
|
])
|
|
},
|
|
{
|
|
sheetName: 'Branch Target',
|
|
title: 'Branch-wise Disbursement Target — Annual',
|
|
subtitle,
|
|
headers: [
|
|
'Branch',
|
|
'Target',
|
|
'Disbursed (Actual)',
|
|
'Achievement %',
|
|
'Status'
|
|
],
|
|
widths: [
|
|
20,
|
|
18,
|
|
18,
|
|
14,
|
|
16
|
|
],
|
|
rows: this.branchTargetRows.map((r) => [
|
|
r.branchName,
|
|
r.target,
|
|
r.actual,
|
|
Number(r.achievementPct.toFixed(1)),
|
|
this.statusLabelOf(r.status)
|
|
])
|
|
},
|
|
{
|
|
sheetName: 'Product-Channel Target',
|
|
title: 'Product & Channel Mix — Target % vs Actual %',
|
|
subtitle,
|
|
headers: [
|
|
'Mix',
|
|
'Target Basis',
|
|
'Target %',
|
|
'Actual %',
|
|
'Variance (pp)',
|
|
'Status'
|
|
],
|
|
widths: [
|
|
26,
|
|
18,
|
|
12,
|
|
12,
|
|
14,
|
|
16
|
|
],
|
|
rows: [
|
|
...this.productTargetRows.map((r) => [
|
|
`Product — ${r.label}`,
|
|
r.isRbiMandated ? 'RBI PSL Sub-Target' : 'Internal Target',
|
|
r.targetPct,
|
|
Number(r.actualPct.toFixed(1)),
|
|
Number(r.variancePct.toFixed(1)),
|
|
this.statusLabelOf(r.status)
|
|
]),
|
|
...this.channelTargetRows.map((r) => [
|
|
`Channel — ${r.label}`,
|
|
'Internal Target',
|
|
r.targetPct,
|
|
Number(r.actualPct.toFixed(1)),
|
|
Number(r.variancePct.toFixed(1)),
|
|
this.statusLabelOf(r.status)
|
|
])
|
|
]
|
|
}
|
|
];
|
|
await exportWorkbook(sheets, `Loan-Origination-${this.scopeName}-${this.asOnDate}`);
|
|
} catch (err) {
|
|
console.error('Loan Origination export failed', err);
|
|
} finally {
|
|
this.generating = false;
|
|
}
|
|
}
|
|
|
|
private toast(msg: string): void {
|
|
this.toastMsg = msg;
|
|
this.toastVisible = true;
|
|
if (this.toastTimer) {
|
|
clearTimeout(this.toastTimer);
|
|
}
|
|
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
|
|
}
|
|
}
|