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>
980 lines
31 KiB
TypeScript
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);
|
|
}
|
|
}
|