/** * 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 | 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(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 { 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); } }