Files
cbsnexora/src/app/loan-origination/loan-pipeline/loan-pipeline.component.ts
T
sonaliandClaude Sonnet 5 9bde697925 feat(loan-origination): wire pipeline and application detail to real branches and saved products
New loan application now sources branches and product type/sub-type/code
options from Product Creation's saved products (Gold, Warehouse Receipt,
Loans/EMI) and real office branches instead of hardcoded lists, with
tenure constrained to each product's min/max.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-22 17:59:34 +05:30

980 lines
31 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 { ClientsService } from '../../clients/clients.service';
import { ExportSheetSpec, exportWorkbook } from '../../cashier/cashier-export';
import { LoanOriginationService, officeBranchCode } from '../loan-origination.service';
import { LoanProductCatalogService } from '../../loans/services/loan-product-catalog.service';
import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS } from '../../products/loan-products/loan-product-types';
import {
BranchMaster,
branchTargetAmount,
Channel,
CHANNEL_LABELS,
CHANNEL_TARGET_PCT,
CustomerType,
generateSeedApplications,
LoanApplication,
ORIGINATION_PRODUCTS,
registerOriginationProduct,
OriginationStage,
OverallStatus,
PRODUCT_TARGET_PCT,
ProductType,
SHARE_LINKAGE_SLABS,
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;
}
interface AmountSlabRow {
minAmt: number;
maxAmt: number | null;
sharePercent: number;
/** Share Capital required at the slab's lower and upper bound, so the rupee amount is visible
* without officers having to do the multiplication themselves. */
shareAtMin: number;
shareAtMax: number | null;
}
type ViewId = 'pipeline' | 'new' | 'channel' | 'bc' | 'share' | 'target' | 'rates';
/**
* 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 applications loaded from the
* database, 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;
/** The bank's offices (real). Until they load, the logged-in user's own office is the only choice. */
branches: BranchMaster[] = [];
/** Active products saved in Product Creation -- the only products an application can be taken for. */
dbProducts: any[] = [];
loadingProducts = true;
readonly productGroups = PRODUCT_TYPE_GROUPS;
readonly products = ORIGINATION_PRODUCTS;
readonly shareLinkageSlabs = SHARE_LINKAGE_SLABS;
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: '',
clientId: null as number | null,
branchCode: '',
productGroup: '',
productSubType: '',
productCode: '',
productType: 'TERM_LOAN' as ProductType,
requestedAmount: null as number | null,
tenureMonths: null as number | null
};
/** Sub types of the chosen Product Type that actually have an active product saved. */
get subTypeOptions() {
const group = this.newApp.productGroup;
return (PRODUCT_SUB_TYPES[group] ?? []).filter((t) =>
this.dbProducts.some(
(p) => p.advanceExtensions?.productTypeGroup === group && p.advanceExtensions?.productTypeSubGroup === t.id
)
);
}
/** The products saved in the database for the chosen Product Type and Sub Type. */
get productOptions(): any[] {
return this.dbProducts.filter(
(p) =>
p.advanceExtensions?.productTypeGroup === this.newApp.productGroup &&
p.advanceExtensions?.productTypeSubGroup === this.newApp.productSubType
);
}
/** Only the tenures the chosen product allows (its min to max, in months). */
get tenureOptions(): number[] {
const p = this.selectedProduct;
if (!p) {
return [];
}
return Array.from({ length: Math.max(0, p.maxTenure - p.minTenure + 1) }, (_, i) => p.minTenure + i);
}
get selectedProduct() {
return this.products.find((p) => p.code === this.newApp.productCode) || null;
}
onProductGroupChange(): void {
this.newApp.productSubType = '';
this.newApp.productCode = '';
}
onSubTypeChange(): void {
const options = this.productOptions;
this.newApp.productCode = options.length === 1 ? options[0].shortName : '';
this.onProductChange();
}
/** Advances have no EMI (revolving limit); Loans are EMI term loans. */
onProductChange(): void {
this.newApp.productType = this.newApp.productGroup === 'advances' ? 'OVERDRAFT' : 'TERM_LOAN';
const product = this.selectedProduct;
this.newApp.tenureMonths = product ? product.minTenure : null;
}
/** Existing-customer search (real Fineract clients, unlike the rest of this mock pipeline). */
existingClientSearchText = '';
existingClientSearchResults: any[] = [];
searchingExistingClient = false;
private existingClientSearchTimer: any;
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly auth = inject(AuthenticationService);
private readonly clientsService = inject(ClientsService);
private readonly originationService = inject(LoanOriginationService);
private readonly catalog = inject(LoanProductCatalogService);
/** The live book, loaded from the database. Every count, report and filter on this screen derives
* from this one array, so the list can never disagree with what an application actually says. */
applications: LoanApplication[] = [];
loadingApplications = false;
savingNewApplication = false;
ngOnInit(): void {
const requestedView = this.route.snapshot.queryParamMap.get('view') as ViewId | null;
if (requestedView && [
'pipeline',
'new',
'channel',
'bc',
'share',
'target',
'rates'
].includes(requestedView)) {
this.activeView = requestedView;
}
// Lets a menu link (e.g. "Loan Committee") land here pre-filtered to just the applications
// waiting on that one stage, reusing this same pipeline list/filter instead of a separate
// queue view -- same pattern as the `view` query param above.
const requestedStage = this.route.snapshot.queryParamMap.get('stage') as OriginationStage | null;
if (requestedStage && this.stageOrder.includes(requestedStage)) {
this.stageFilter = requestedStage;
}
const creds = this.auth.getCredentials();
this.isHeadOffice = this.auth.isHeadOfficeUser();
this.currentOfficeName = creds?.officeName || 'Branch Office';
this.ownBranchCode = creds?.officeId ? officeBranchCode(creds.officeId) : null;
// The logged-in user's own office is the default branch. A branch-level login can only originate for its own branch:
// the select is disabled and `saveNewApplication` forces the code again, so it is locked here and not just hidden.
this.branches = this.ownBranchCode ? [{ code: this.ownBranchCode, name: this.currentOfficeName }] : [];
this.branchFilter = this.isHeadOffice ? '' : this.ownBranchCode || '';
this.newApp.branchCode = this.ownBranchCode || '';
this.catalog.activeProducts().subscribe((products) => {
this.dbProducts = products.filter((p) => p.shortName && p.advanceExtensions?.productTypeGroup);
this.dbProducts.forEach((p) => registerOriginationProduct(p));
this.loadingProducts = false;
});
this.originationService.listBranches().subscribe({
next: (offices) => {
if (offices.length > 0) {
this.branches = offices;
}
},
error: () => {
/* keep the user's own office as the only choice */
}
});
this.loadApplications();
}
/** Loads the book from the database. On a completely fresh install the pipeline is empty, so the
* demo book is seeded once and then re-read — the seed endpoint no-ops when rows already exist,
* so reloading this screen can never duplicate it. */
private loadApplications(): void {
this.loadingApplications = true;
this.originationService.list().subscribe({
next: (apps: LoanApplication[]) => {
if (apps.length === 0) {
this.seedThenReload();
return;
}
this.applications = apps;
this.loadingApplications = false;
},
error: () => {
this.applications = [];
this.loadingApplications = false;
this.toast('Could not load the loan origination pipeline.');
}
});
}
private seedThenReload(): void {
this.originationService.seed(generateSeedApplications()).subscribe({
next: () => {
this.originationService.list().subscribe({
next: (seeded: LoanApplication[]) => {
this.applications = seeded;
this.loadingApplications = false;
},
error: () => {
this.applications = [];
this.loadingApplications = false;
}
});
},
error: () => {
this.applications = [];
this.loadingApplications = false;
this.toast('Could not initialise the loan origination pipeline.');
}
});
}
/** 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 ? this.applications.filter((a) => a.branchCode === this.branchFilter) : this.applications;
}
private get currentUserName(): string {
return (this.auth.getCredentials()?.username ?? '').toLowerCase();
}
/** Applications with an approval sent and waiting for a decision (maker-checker), newest first. */
get awaitingApproval() {
const rows: {
app: LoanApplication;
stage: string;
sentBy: string;
sentOn: string;
mine: boolean;
exception: boolean;
}[] = [];
this.applications.forEach((app) => {
Object.entries(app.approvalRequests ?? {}).forEach(
([
,
request
]) => {
if (request && request.status === 'PENDING') {
rows.push({
app,
stage: STAGE_LABELS[app.currentStage],
sentBy: request.submittedBy,
sentOn: request.submittedOn,
mine: (request.submittedBy ?? '').toLowerCase() === this.currentUserName,
exception: request.requestedDecision === 'EXCEPTION_APPROVED'
});
}
}
);
});
return rows;
}
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 = this.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'
};
});
}
// -------------------- Interest & Share Linkage Reference --------------------
/** Illustrative total interest over the product's own tenure band, simple-interest approximation
* (principal x rate x years) -- flagged as illustrative in the template since actual repayment
* is on a reducing balance and varies by amount/tenure chosen at sanction. */
illustrativeInterest(amount: number, ratePct: number, tenureMonths: number): number {
return amount * (ratePct / 100) * (tenureMonths / 12);
}
get shareLinkageSlabRows(): AmountSlabRow[] {
return this.shareLinkageSlabs.map((slab) => ({
...slab,
shareAtMin: slab.minAmt * (slab.sharePercent / 100),
shareAtMax: slab.maxAmt !== null ? slab.maxAmt * (slab.sharePercent / 100) : null
}));
}
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);
}
/** Switching Customer Type away from "Existing" drops any client picked under the old mode --
* avoids storing a stale clientId against a now-"New Customer" application. */
onCustomerTypeChange(): void {
if (this.newApp.customerType === 'NEW') {
this.clearExistingClient();
}
}
onExistingClientSearch(text: string): void {
this.existingClientSearchText = text;
clearTimeout(this.existingClientSearchTimer);
if (!text || text.length < 2) {
this.existingClientSearchResults = [];
return;
}
this.searchingExistingClient = true;
this.existingClientSearchTimer = setTimeout(() => {
this.clientsService.searchByText(text, 0, 8, '', '', true, 'INDIVIDUAL').subscribe({
next: (data: any) => {
this.existingClientSearchResults = data?.content || [];
this.searchingExistingClient = false;
},
error: () => {
this.existingClientSearchResults = [];
this.searchingExistingClient = false;
}
});
}, 400);
}
selectExistingClient(client: any): void {
this.newApp.clientId = client.id;
this.newApp.applicantName = client.displayName;
this.existingClientSearchResults = [];
this.existingClientSearchText = '';
}
clearExistingClient(): void {
this.newApp.clientId = null;
this.newApp.applicantName = '';
this.existingClientSearchText = '';
this.existingClientSearchResults = [];
}
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.customerType === 'EXISTING' && !this.newApp.clientId) {
this.toast('Search and select the existing client for this application.');
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] || { code: this.newApp.branchCode, name: this.currentOfficeName };
const product = this.productOf(this.newApp.productCode);
if (!product) {
this.toast('Select the Product Type, Sub Type and Product.');
return;
}
if (
this.newApp.requestedAmount &&
(this.newApp.requestedAmount < product.minAmt || this.newApp.requestedAmount > product.maxAmt)
) {
this.toast(
`Amount must be between ₹${product.minAmt.toLocaleString('en-IN')} and ₹${product.maxAmt.toLocaleString('en-IN')} for ${product.name}.`
);
return;
}
const tenure = this.newApp.tenureMonths || product.minTenure;
if (tenure < product.minTenure || tenure > product.maxTenure) {
this.toast(`Tenure must be between ${product.minTenure} and ${product.maxTenure} months for ${product.name}.`);
return;
}
const seq = this.applications.length + 20000;
const app = createApplication(
{
channel: this.newApp.channel,
bcAgentName: this.newApp.bcAgentName || '—',
customerType: this.newApp.customerType,
applicantName: this.newApp.applicantName.trim(),
clientId: this.newApp.clientId,
branchCode: branch.code,
branchName: branch.name,
productCode: product.code,
productName: product.name,
productType: this.newApp.productType,
secured: product.secured,
requestedAmount: this.newApp.requestedAmount,
tenureMonths: this.newApp.tenureMonths || product.minTenure,
interestRate: +((product.minRate + product.maxRate) / 2).toFixed(2)
},
seq
);
// Only navigate once the application is actually stored -- an officer who lands on the detail
// screen for something that failed to save would fill in a whole stage against nothing.
this.savingNewApplication = true;
this.originationService.create(app).subscribe({
next: (saved: LoanApplication) => {
this.savingNewApplication = false;
this.applications.unshift(saved);
this.toast(`Application ${saved.applicationNo} received for ${saved.applicantName}.`);
this.newApp = {
channel: 'BRANCH',
bcAgentName: '',
customerType: 'NEW',
applicantName: '',
clientId: null,
branchCode: this.ownBranchCode || this.branches[0]?.code || '',
productGroup: '',
productSubType: '',
productCode: '',
productType: 'TERM_LOAN',
requestedAmount: null,
tenureMonths: null
};
this.existingClientSearchText = '';
this.existingClientSearchResults = [];
this.router.navigate([
'/loan-origination/application',
saved.applicationNo
]);
},
error: (err: any) => {
this.savingNewApplication = false;
this.toast(
err?.error?.errors?.[0]?.defaultUserMessage || 'Could not save the application. Nothing was recorded.'
);
}
});
}
// -------------------- 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);
}
}