Files
cbsnexora/src/app/loan-origination/loan-pipeline/loan-pipeline.component.ts
T
niteshandClaude Sonnet 5 ebe67cc410 feat(investment,npa): RBI-grounded targets, GL-tracked interest posting, date/search pickers
- 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>
2026-08-02 19:26:19 +05:30

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);
}
}