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