feat(loan-origination): add full Loan Origination Sanctioning workflow module

New standalone module modelling the pre-disbursement pipeline: Channel intake
(Branch/BC/Self-Apply) -> KYC/OVD & e-KYC fingerprint -> CIC/Credit Bureau
check -> Field Investigation -> Personal Discussion & declaration -> Loan
Committee -> Risk Approval -> Legal Approval -> Customer Signing -> Cheque/ACH
-> Post-Disbursement Check -> Disbursement, per RBI KYC Master Direction, the
mandatory CIC check, and co-operative-bank share-membership rules.

Includes a 12-stage stepper with real approve/reject actions and a date-wise
audit trail, CIC upload + credit-score check, Field Inquiry push + Agent
Report upload, Committee "Reports Required" / Risk "Verification Checklist",
Head-Office-vs-branch scoping, a Pipeline screen (filters, New Application
intake, Channel-wise/BC-wise/Share-Member reports, Disbursement Target vs
Actual, Export to Excel), and a full EMI repayment schedule once disbursed.
This commit is contained in:
2026-08-02 14:22:32 +05:30
parent b4608cbbe4
commit 5cd9998cfc
10 changed files with 4474 additions and 0 deletions
@@ -0,0 +1,558 @@
<!--
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/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">CREDIT · LOAN ORIGINATION · {{ scopeName | uppercase }}</div>
<h1 class="pg-title">Loan Origination Pipeline</h1>
<p class="pg-sub npa-report-note">
Every sanctioning stage from application intake to disbursement — KYC/OVD, CIC credit check, Field
Investigation, Personal Discussion, Credit Committee, Risk &amp; Legal Approval — as on {{ asOnDate }}.
</p>
</div>
<div class="pg-toolbar">
@if (isHeadOffice) {
<label class="pg-field">
<span>Branch</span>
<select class="pg-select" [ngModel]="branchFilter" (ngModelChange)="setBranch($event)">
<option value="">-- All Branches --</option>
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
}
</select>
</label>
} @else {
<label class="pg-field">
<span>Branch</span>
<span class="pg-chip">{{ scopeName }}</span>
</label>
}
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
</div>
</header>
<div class="inv-kpis npa-kpis--ratio">
<div class="inv-kpi">
<span class="inv-kpi__label">Total Applications</span>
<span class="inv-kpi__value">{{ totalApplications }}</span>
<span class="inv-kpi__sub">in the active pipeline</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">In Progress</span>
<span class="inv-kpi__value">{{ inProgressCount }}</span>
<span class="inv-kpi__sub">moving through the workflow</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">On Hold</span>
<span class="inv-kpi__value npa-warn">{{ onHoldCount }}</span>
<span class="inv-kpi__sub">discrepancy under resolution</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Rejected</span>
<span class="inv-kpi__value npa-warn">{{ rejectedCount }}</span>
<span class="inv-kpi__sub">declined at Committee/Risk</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Disbursed</span>
<span class="inv-kpi__value">{{ disbursedApplications.length }}</span>
<span class="inv-kpi__sub">₹{{ disbursedAmount | number: '1.0-0' }} total</span>
</div>
</div>
<div class="cctr-tabs">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeView === 'pipeline'"
(click)="setView('pipeline')"
>
Pipeline
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeView === 'new'"
(click)="setView('new')"
>
New Application
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeView === 'channel'"
(click)="setView('channel')"
>
Channel-wise Report
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeView === 'bc'"
(click)="setView('bc')"
>
BC-wise Report
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeView === 'share'"
(click)="setView('share')"
>
Share Member Report
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeView === 'target'"
(click)="setView('target')"
>
Target vs Actual
</button>
</div>
<!-- ===================== PIPELINE ===================== -->
@if (activeView === 'pipeline') {
<div class="pg-card">
<div class="pos-section-label">Filters</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Channel</span>
<select [(ngModel)]="channelFilter" (ngModelChange)="currentPage = 1">
<option value="">-- All --</option>
<option value="BRANCH">Branch</option>
<option value="BC">Business Correspondent (BC)</option>
<option value="SELF_APPLY">Self-Apply (Digital)</option>
</select>
</label>
<label class="inv-form-field">
<span>Stage</span>
<select [(ngModel)]="stageFilter" (ngModelChange)="currentPage = 1">
<option value="">-- All --</option>
@for (s of stageOrder; track s) {
<option [value]="s">{{ stageLabels[s] }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Status</span>
<select [(ngModel)]="statusFilter" (ngModelChange)="currentPage = 1">
<option value="">-- All --</option>
<option value="IN_PROGRESS">In Progress</option>
<option value="ON_HOLD">On Hold</option>
<option value="REJECTED">Rejected</option>
<option value="DISBURSED">Disbursed</option>
</select>
</label>
<label class="inv-form-field">
<span>Search (Application No / Applicant)</span>
<input
type="text"
[(ngModel)]="searchText"
(ngModelChange)="currentPage = 1"
placeholder="e.g. Ramesh, LN0102..."
/>
</label>
</div>
</div>
<div class="pg-card">
<div class="pos-section-label">Applications ({{ filteredApplications.length }})</div>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>Application No</th>
<th>Applicant</th>
<th>Branch</th>
<th>Channel</th>
<th>Product</th>
<th class="num">Requested Amount</th>
<th>Current Stage</th>
<th class="num">Progress</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (a of paged(filteredApplications); track a.applicationNo) {
<tr class="cctr-branch-row" (click)="openApplication(a)">
<td>
<span class="pg-chip">{{ a.applicationNo }}</span>
</td>
<td>{{ a.applicantName }}</td>
<td>{{ a.branchName }}</td>
<td>{{ channelLabels[a.channel] }}</td>
<td>{{ a.productName }}</td>
<td class="num">₹{{ a.requestedAmount | number: '1.0-0' }}</td>
<td>{{ stageLabel(a.currentStage) }}</td>
<td class="num">{{ progress(a) }}%</td>
<td>
<span [class]="badgeClass(a)">{{ statusLabel(a.overallStatus) }}</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="pg-empty">No applications match these filters.</td>
</tr>
}
</tbody>
</table>
</div>
@if (filteredApplications.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(filteredApplications) }}–{{ rangeEnd(filteredApplications) }} of
{{ filteredApplications.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, filteredApplications)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(filteredApplications) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(filteredApplications)"
(click)="goToPage(currentPage + 1, filteredApplications)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== NEW APPLICATION ===================== -->
@if (activeView === 'new') {
<div class="pg-card">
<div class="pos-section-label">New Application — Channel Intake</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>* Channel</span>
<select [(ngModel)]="newApp.channel">
<option value="BRANCH">Branch</option>
<option value="BC">Business Correspondent (BC)</option>
<option value="SELF_APPLY">Self-Apply (Digital)</option>
</select>
</label>
@if (newApp.channel === 'BC') {
<label class="inv-form-field">
<span>* BC Agent / Point</span>
<input type="text" [(ngModel)]="newApp.bcAgentName" placeholder="e.g. Suvidha BC Point — Andheri" />
</label>
}
<label class="inv-form-field">
<span>* Customer Type</span>
<select [(ngModel)]="newApp.customerType">
<option value="NEW">New Customer</option>
<option value="EXISTING">Existing Customer</option>
</select>
</label>
<label class="inv-form-field">
<span>* Applicant Name</span>
<input type="text" [(ngModel)]="newApp.applicantName" placeholder="Full name" />
</label>
<label class="inv-form-field">
<span>* Branch</span>
<select [(ngModel)]="newApp.branchCode" [disabled]="!isHeadOffice">
@for (b of newApplicationBranches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>* Product</span>
<select [(ngModel)]="newApp.productCode">
@for (p of products; track p.code) {
<option [value]="p.code">{{ p.name }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>* Requested Amount</span>
<input type="number" [(ngModel)]="newApp.requestedAmount" placeholder="₹" />
</label>
<label class="inv-form-field">
<span>Tenure (Months)</span>
<input type="number" [(ngModel)]="newApp.tenureMonths" placeholder="e.g. 36" />
</label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="saveNewApplication()">Submit Application</button>
</div>
</div>
}
<!-- ===================== CHANNEL-WISE REPORT ===================== -->
@if (activeView === 'channel') {
<div class="pg-card">
<div class="pos-section-label">Channel-wise Report — Branch / BC / Self-Apply</div>
<table class="pg-table">
<thead>
<tr>
<th>Channel</th>
<th class="num">Applications</th>
<th class="num">Requested Amount</th>
<th class="num col-in">Disbursed Count</th>
<th class="num col-in">Disbursed Amount</th>
</tr>
</thead>
<tbody>
@for (r of channelReport; track r.channel) {
<tr>
<td>{{ r.label }}</td>
<td class="num">{{ r.count }}</td>
<td class="num">₹{{ r.requestedAmount | number: '1.0-0' }}</td>
<td class="num col-in">{{ r.disbursedCount }}</td>
<td class="num col-in">₹{{ r.disbursedAmount | number: '1.0-0' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td>Total</td>
<td class="num">{{ totalApplications }}</td>
<td class="num">
₹{{
channelReport[0].requestedAmount + channelReport[1].requestedAmount + channelReport[2].requestedAmount
| number: '1.0-0'
}}
</td>
<td class="num">{{ disbursedApplications.length }}</td>
<td class="num">₹{{ disbursedAmount | number: '1.0-0' }}</td>
</tr>
</tfoot>
</table>
</div>
}
<!-- ===================== BC-WISE REPORT ===================== -->
@if (activeView === 'bc') {
<div class="pg-card">
<div class="pos-section-label">
BC-wise Report — Business Correspondent Sourced Applications ({{ bcApplications.length }})
</div>
<table class="pg-table">
<thead>
<tr>
<th>BC Agent / Point</th>
<th class="num">Applications</th>
<th class="num">Requested Amount</th>
<th class="num col-in">Disbursed Count</th>
<th class="num col-in">Disbursed Amount</th>
</tr>
</thead>
<tbody>
@for (r of bcReport; track r.bcAgentName) {
<tr>
<td>{{ r.bcAgentName }}</td>
<td class="num">{{ r.count }}</td>
<td class="num">₹{{ r.requestedAmount | number: '1.0-0' }}</td>
<td class="num col-in">{{ r.disbursedCount }}</td>
<td class="num col-in">₹{{ r.disbursedAmount | number: '1.0-0' }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="pg-empty">No BC-sourced applications in this branch.</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- ===================== SHARE MEMBER REPORT ===================== -->
@if (activeView === 'share') {
<div class="pg-card">
<div class="pos-section-label">Share Member Report</div>
<p class="pg-sub npa-report-note">
A cooperative-bank borrower must hold membership shares in the society before a loan can be sanctioned.
{{ shareMemberCount }} of {{ totalApplications }} applicants are already members; the rest need membership
allotted before Credit Committee approval — done from the application's own detail screen.
</p>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>Application No</th>
<th>Applicant</th>
<th>Branch</th>
<th>Current Stage</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (a of paged(nonMemberApplications); track a.applicationNo) {
<tr class="cctr-branch-row" (click)="openApplication(a)">
<td>
<span class="pg-chip">{{ a.applicationNo }}</span>
</td>
<td>{{ a.applicantName }}</td>
<td>{{ a.branchName }}</td>
<td>{{ stageLabel(a.currentStage) }}</td>
<td>
<span [class]="badgeClass(a)">{{ statusLabel(a.overallStatus) }}</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="pg-empty">Every active applicant in this branch already holds membership.</td>
</tr>
}
</tbody>
</table>
</div>
@if (nonMemberApplications.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(nonMemberApplications) }}–{{ rangeEnd(nonMemberApplications) }} of
{{ nonMemberApplications.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, nonMemberApplications)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(nonMemberApplications) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(nonMemberApplications)"
(click)="goToPage(currentPage + 1, nonMemberApplications)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== TARGET VS ACTUAL ===================== -->
@if (activeView === 'target') {
<div class="pg-card">
<div class="pos-section-label">Branch-wise Disbursement Target — Annual</div>
<table class="pg-table">
<thead>
<tr>
<th>Branch</th>
<th class="num">Target</th>
<th class="num col-in">Disbursed (Actual)</th>
<th class="num">Achievement %</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (row of branchTargetRows; track row.branchCode) {
<tr>
<td>{{ row.branchName }}</td>
<td class="num">₹{{ row.target | number: '1.0-0' }}</td>
<td class="num col-in">₹{{ row.actual | number: '1.0-0' }}</td>
<td class="num">{{ row.achievementPct | number: '1.1-1' }}%</td>
<td>
<span
class="loan-badge"
[class.loan-badge--disbursed]="row.status !== 'BEHIND'"
[class.loan-badge--hold]="row.status === 'BEHIND'"
>
{{ statusLabelOf(row.status) }}
</span>
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="pg-card">
<div class="pos-section-label">Product Mix — Target % vs Actual % (of Disbursed Amount)</div>
<table class="pg-table">
<thead>
<tr>
<th>Product</th>
<th class="num">Target %</th>
<th class="num col-in">Actual %</th>
<th class="num">Variance</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (row of productTargetRows; track row.code) {
<tr>
<td>{{ row.label }}</td>
<td class="num">{{ row.targetPct | number: '1.0-0' }}%</td>
<td class="num col-in">{{ row.actualPct | number: '1.1-1' }}%</td>
<td class="num" [class.col-out]="row.variancePct < 0" [class.col-in]="row.variancePct > 0">
{{ row.variancePct > 0 ? '+' : '' }}{{ row.variancePct | number: '1.1-1' }} pp
</td>
<td>
<span
class="loan-badge"
[class.loan-badge--disbursed]="row.status !== 'BEHIND'"
[class.loan-badge--hold]="row.status === 'BEHIND'"
>
{{ statusLabelOf(row.status) }}
</span>
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="pg-card">
<div class="pos-section-label">Channel Mix — Target % vs Actual % (of Disbursed Amount)</div>
<table class="pg-table">
<thead>
<tr>
<th>Channel</th>
<th class="num">Target %</th>
<th class="num col-in">Actual %</th>
<th class="num">Variance</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (row of channelTargetRows; track row.code) {
<tr>
<td>{{ row.label }}</td>
<td class="num">{{ row.targetPct | number: '1.0-0' }}%</td>
<td class="num col-in">{{ row.actualPct | number: '1.1-1' }}%</td>
<td class="num" [class.col-out]="row.variancePct < 0" [class.col-in]="row.variancePct > 0">
{{ row.variancePct > 0 ? '+' : '' }}{{ row.variancePct | number: '1.1-1' }} pp
</td>
<td>
<span
class="loan-badge"
[class.loan-badge--disbursed]="row.status !== 'BEHIND'"
[class.loan-badge--hold]="row.status === 'BEHIND'"
>
{{ statusLabelOf(row.status) }}
</span>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
@@ -0,0 +1,675 @@
/**
* 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;
}
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)
};
});
}
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)
};
});
}
// -------------------- 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 %',
'Actual %',
'Variance (pp)',
'Status'
],
widths: [
26,
12,
12,
14,
16
],
rows: [
...this.productTargetRows.map((r) => [
`Product — ${r.label}`,
r.targetPct,
Number(r.actualPct.toFixed(1)),
Number(r.variancePct.toFixed(1)),
this.statusLabelOf(r.status)
]),
...this.channelTargetRows.map((r) => [
`Channel — ${r.label}`,
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);
}
}