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
+2
View File
@@ -69,6 +69,7 @@ import { SftModule } from './sft/sft.module';
import { InvestmentModule } from './investment/investment.module';
import { NpaModule } from './npa/npa.module';
import { ReconciliationModule } from './reconciliation/reconciliation.module';
import { LoanOriginationModule } from './loan-origination/loan-origination.module';
import { DigitalModule } from './digital/digital.module';
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
import { AccountsModule } from './accounts/accounts.module';
@@ -180,6 +181,7 @@ export function HttpLoaderFactory(http: HttpClient) {
InvestmentModule,
NpaModule,
ReconciliationModule,
LoanOriginationModule,
DigitalModule,
LetterMgmtModule,
AccountsModule,
+41
View File
@@ -350,6 +350,10 @@
<fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="loanOriginationMenu" *mifosxHasPermission="'MENU_ADVANCES'">
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="advancesMenu" *mifosxHasPermission="'MENU_ADVANCES'">
<fa-icon icon="hand-holding-usd" class="ni-ico"></fa-icon>Advances
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
@@ -442,6 +446,10 @@
<fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="loanOriginationMenu" *mifosxHasPermission="'MENU_ADVANCES'">
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a
class="ni"
tabindex="0"
@@ -1300,6 +1308,39 @@
</div>
</mat-menu>
<mat-menu #loanOriginationMenu="matMenu" class="npa-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid npa-grid" (click)="$event.stopPropagation()">
<!-- Loan Origination -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Loan Origination</div>
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline">
<fa-icon icon="stream" class="menu-item-icon"></fa-icon> Application Pipeline
</button>
</div>
<!-- Reports -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Reports</div>
<button
mat-menu-item
tabindex="0"
routerLink="/loan-origination/pipeline"
[queryParams]="{ view: 'channel' }"
>
<fa-icon icon="chart-pie" class="menu-item-icon"></fa-icon> Channel-wise Report
</button>
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline" [queryParams]="{ view: 'bc' }">
<fa-icon icon="mobile-alt" class="menu-item-icon"></fa-icon> BC-wise Report
</button>
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline" [queryParams]="{ view: 'share' }">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Share Member Report
</button>
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline" [queryParams]="{ view: 'target' }">
<fa-icon icon="chart-column" class="menu-item-icon"></fa-icon> Target vs Actual
</button>
</div>
</div>
</mat-menu>
<mat-menu #ckycMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item routerLink="/ckyc" tabindex="0">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> CKYC Management
@@ -0,0 +1,878 @@
<!--
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">
@if (application; as a) {
<header class="pg-head">
<div>
<div class="pg-eyebrow">CREDIT · LOAN ORIGINATION · {{ a.branchName | uppercase }}</div>
<h1 class="pg-title">
{{ a.applicationNo }} — {{ a.applicantName }}
<span [class]="badgeClass(a)">{{ statusLabels[a.overallStatus] }}</span>
</h1>
<p class="pg-sub npa-report-note">
{{ a.productName }} · ₹{{ a.requestedAmount | number: '1.0-0' }} · Applied {{ a.applicationDate }} ·
{{ progress(a) }}% through the sanctioning workflow.
</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn pg-btn--ghost" (click)="goBack()">‹ Back to Pipeline</button>
</div>
</header>
<div class="pg-card">
<dl class="loan-kv-grid">
<div class="loan-kv-field">
<dt>Branch</dt>
<dd>{{ a.branchName }}</dd>
</div>
<div class="loan-kv-field">
<dt>Channel</dt>
<dd>
{{ a.channel === 'BC' ? a.bcAgentName : a.channel === 'SELF_APPLY' ? 'Self-Apply (Digital)' : 'Branch' }}
</dd>
</div>
<div class="loan-kv-field">
<dt>Customer Type</dt>
<dd>{{ a.customerType === 'EXISTING' ? 'Existing Customer' : 'New Customer' }}</dd>
</div>
<div class="loan-kv-field">
<dt>Tenure / Rate</dt>
<dd>{{ a.tenureMonths }} months @ {{ a.interestRate }}%</dd>
</div>
<div class="loan-kv-field">
<dt>Monthly Income / Eligible Amount</dt>
<dd>₹{{ a.monthlyIncome | number: '1.0-0' }} / ₹{{ a.eligibleLoanAmount | number: '1.0-0' }}</dd>
</div>
<div class="loan-kv-field">
<dt>Sanctioning Authority</dt>
<dd>{{ sanctioningAuthorityOf(a) }}</dd>
</div>
<div class="loan-kv-field">
<dt>Share Member</dt>
<dd>
@if (a.isShareMember) {
Yes — {{ a.shareCertNo }} (since {{ a.shareMemberSince }})
} @else {
<span class="loan-badge loan-badge--hold">Not a Member</span>
<button type="button" class="pg-btn pg-btn--ghost" (click)="allotMembership(a)">Allot Shares</button>
}
</dd>
</div>
@if (a.coBorrowerRequired) {
<div class="loan-kv-field">
<dt>Co-Borrower</dt>
<dd>{{ a.coBorrowerName }}</dd>
</div>
}
@if (a.guarantorRequired) {
<div class="loan-kv-field">
<dt>Guarantor</dt>
<dd>{{ a.guarantorName }}</dd>
</div>
}
</dl>
</div>
<div class="pg-card">
<div class="pos-section-label">Sanctioning Workflow Progress</div>
<div class="loan-stepper">
@for (s of stageOrder; track s) {
<div [class]="stepStatusClass(a, s)">
<span class="loan-step__connector"></span>
<span class="loan-step__dot">{{ stepMarker(a, s) }}</span>
<span class="loan-step__label">{{ stageLabels[s] }}</span>
</div>
}
</div>
</div>
@if (a.overallStatus === 'REJECTED') {
<div class="pg-card">
<div class="pos-section-label">Application Rejected — at {{ stageLabels[a.currentStage] }}</div>
<p class="pg-sub npa-report-note">See the approval summary below for the recorded reason.</p>
</div>
} @else if (a.overallStatus === 'ON_HOLD') {
<div class="pg-card">
<div class="pos-section-label">Personal Discussion — {{ discrepancyLabels[a.discrepancyType] }}</div>
<div class="loan-action-card">
<p>{{ a.personalDiscussionNotes }}</p>
<label class="inv-form-field">
<span>Resolution Notes</span>
<input type="text" [(ngModel)]="actionRemarks" placeholder="How was this resolved?" />
</label>
<div class="loan-action-row">
<button type="button" class="pg-btn pg-btn--success" (click)="resolveDiscrepancy(a)">
Resolve &amp; Proceed to Loan Committee
</button>
</div>
</div>
</div>
} @else if (a.overallStatus === 'DISBURSED') {
<div class="pg-card">
<div class="pos-section-label">Disbursed</div>
<dl class="loan-kv-grid">
<div class="loan-kv-field">
<dt>Disbursement Date</dt>
<dd>{{ a.disbursementDate }}</dd>
</div>
<div class="loan-kv-field">
<dt>Mode</dt>
<dd>{{ a.disbursementMode }}</dd>
</div>
<div class="loan-kv-field">
<dt>ACH Confirmation</dt>
<dd>{{ a.achConfirmationRef }}</dd>
</div>
<div class="loan-kv-field">
<dt>EMI Activation Confirmation</dt>
<dd>{{ a.emiActivationConfirmed ? 'Confirmed on ' + a.emiActivationDate : 'Pending' }}</dd>
</div>
</dl>
</div>
<div class="pg-card">
<div class="pos-section-label">EMI Repayment Schedule</div>
@if (isRevolving(a)) {
<p class="pg-sub npa-report-note">
{{ a.productName }} is a revolving/renewable limit — repaid on demand and renewed annually, not on a fixed
EMI schedule.
</p>
} @else {
<div class="inv-kpis npa-kpis--ratio">
<div class="inv-kpi">
<span class="inv-kpi__label">Loan Amount</span>
<span class="inv-kpi__value">₹{{ a.requestedAmount | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">{{ a.tenureMonths }} months @ {{ a.interestRate }}%</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Monthly EMI</span>
<span class="inv-kpi__value">₹{{ emiAmountFor(a) | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">Reducing balance</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Total Interest Payable</span>
<span class="inv-kpi__value">₹{{ totalInterestFor(a) | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">over the full tenure</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Total Amount Payable</span>
<span class="inv-kpi__value">₹{{ totalPayableFor(a) | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">principal + interest</span>
</div>
</div>
<div class="pg-table-scroll loan-emi-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th class="num">Inst. No</th>
<th>Due Date</th>
<th class="num">Opening Balance</th>
<th class="num">EMI</th>
<th class="num">Principal</th>
<th class="num col-out">Interest</th>
<th class="num">Closing Balance</th>
</tr>
</thead>
<tbody>
@for (row of emiScheduleFor(a); track row.installmentNo) {
<tr>
<td class="num">{{ row.installmentNo }}</td>
<td>{{ row.dueDate }}</td>
<td class="num">₹{{ row.openingBalance | number: '1.0-0' }}</td>
<td class="num">₹{{ row.emiAmount | number: '1.0-0' }}</td>
<td class="num col-in">₹{{ row.principalComponent | number: '1.0-0' }}</td>
<td class="num col-out">₹{{ row.interestComponent | number: '1.0-0' }}</td>
<td class="num">₹{{ row.closingBalance | number: '1.0-0' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
} @else {
<!-- ===================== APPLICATION ===================== -->
@if (a.currentStage === 'APPLICATION') {
<div class="pg-card">
<div class="pos-section-label">Application Received</div>
<div class="loan-action-card">
<p>
Received via
{{ a.channel === 'BC' ? a.bcAgentName : a.channel === 'SELF_APPLY' ? 'Self-Apply (Digital)' : 'Branch' }}
on {{ a.applicationDate }}.
</p>
<div class="loan-action-row">
<button
type="button"
class="pg-btn pg-btn--primary"
(click)="proceed(a, 'Application logged and forwarded for KYC.')"
>
Proceed to KYC &amp; Document Collection
</button>
</div>
</div>
</div>
}
<!-- ===================== KYC & DOCUMENTS ===================== -->
@if (a.currentStage === 'KYC_DOCUMENTS') {
<div class="pg-card">
<div class="pos-section-label">KYC &amp; Document Collection — OVD (RBI KYC Master Direction)</div>
<div class="loan-action-card">
<div class="loan-checklist">
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.aadhaarVerified" /> Aadhaar Verified (OVD)
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.panVerified" /> PAN Verified
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.incomeProofSubmitted" /> Income Proof Submitted
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.bankStatementSubmitted" /> Bank Statement Submitted
</label>
@if (a.secured) {
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.propertyDetailsSubmitted" /> Property Details Submitted
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.taxReceiptSubmitted" /> Property Tax Receipt Submitted
</label>
}
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.electricityBillSubmitted" /> Electricity Bill (Address Proof)
Submitted
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.aadhaarFingerprintCaptured" /> Aadhaar Fingerprint Captured
(e-KYC)
</label>
</div>
@if (a.monthlyIncome === 0) {
<label class="inv-form-field">
<span>Monthly Income (₹)</span>
<input type="number" [(ngModel)]="monthlyIncomeInput" placeholder="e.g. 45000" />
</label>
<p class="pg-sub npa-report-note">
Eligible Loan Amount preview: ₹{{ eligiblePreview(a) | number: '1.0-0' }}
</p>
<div class="loan-action-row">
<button type="button" class="pg-btn pg-btn--ghost" (click)="setIncomeNow(a)">
Assess Income &amp; Calculate Eligible Amount
</button>
</div>
} @else {
<dl class="loan-kv-grid" style="padding: 0 0 14px">
<div class="loan-kv-field">
<dt>Monthly Income</dt>
<dd>₹{{ a.monthlyIncome | number: '1.0-0' }}</dd>
</div>
<div class="loan-kv-field">
<dt>Eligible Loan Amount</dt>
<dd>₹{{ a.eligibleLoanAmount | number: '1.0-0' }}</dd>
</div>
</dl>
}
@if (a.coBorrowerRequired) {
<p class="pg-sub npa-report-note">Co-Borrower Required — {{ a.coBorrowerName }}</p>
}
<div class="loan-action-row">
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="!allKycDone(a)"
(click)="proceed(a, 'KYC/OVD documents verified and collected.')"
>
Proceed to CIC / Credit Bureau Check
</button>
</div>
</div>
</div>
}
<!-- ===================== CIC / CREDIT BUREAU CHECK ===================== -->
@if (a.currentStage === 'CIC_CHECK') {
<div class="pg-card">
<div class="pos-section-label">CIC / Credit Bureau Check — {{ a.cicBureau }}</div>
<div class="loan-action-card">
@if (a.cibilScore === 0) {
<label class="inv-form-field">
<span>Bureau</span>
<select [(ngModel)]="a.cicBureau">
@for (bureau of cicBureaus; track bureau) {
<option [value]="bureau">{{ bureau }}</option>
}
</select>
</label>
<div class="loan-action-row">
<button type="button" class="pg-btn pg-btn--primary" (click)="checkCreditScoreNow(a)">
Check Credit Score
</button>
</div>
} @else {
<dl class="loan-kv-grid" style="padding: 0 0 14px">
<div class="loan-kv-field">
<dt>Bureau</dt>
<dd>{{ a.cicBureau }}</dd>
</div>
<div class="loan-kv-field">
<dt>Credit Score</dt>
<dd>{{ a.cibilScore }} ({{ cibilBandOf(a.cibilScore) }})</dd>
</div>
<div class="loan-kv-field">
<dt>Report</dt>
<dd>{{ a.cicReportUploaded ? a.cicReportFileName : 'Not uploaded yet' }}</dd>
</div>
<div class="loan-kv-field">
<dt>Field Inquiry</dt>
<dd>
{{
a.fiRequestPushed
? 'Pushed to ' + a.fiAgencyAssigned + ' (' + a.fiRequestDate + ')'
: 'Not pushed yet'
}}
</dd>
</div>
</dl>
}
@if (a.cibilScore > 0) {
@if (!a.cicReportUploaded) {
<div class="loan-action-row">
<label class="pg-btn pg-btn--ghost" style="cursor: pointer">
Upload {{ a.cicBureau }} Report
<input type="file" hidden accept=".pdf,.jpg,.jpeg,.png" (change)="onCicFileSelected($event, a)" />
</label>
</div>
} @else if (!a.fiRequestPushed) {
<div class="loan-action-row">
<button type="button" class="pg-btn pg-btn--ghost" (click)="pushFieldInquiryNow(a)">
Push Field Inquiry to Agency
</button>
</div>
} @else {
<div class="loan-action-row">
<button
type="button"
class="pg-btn pg-btn--primary"
(click)="
proceed(
a,
a.cicBureau +
' report reviewed — score ' +
a.cibilScore +
' (' +
cibilBandOf(a.cibilScore) +
').'
)
"
>
Proceed to Field Investigation
</button>
</div>
}
}
</div>
</div>
}
<!-- ===================== FIELD INVESTIGATION ===================== -->
@if (a.currentStage === 'FIELD_INVESTIGATION') {
<div class="pg-card">
<div class="pos-section-label">Field Investigation (FI) — Agent Report</div>
<div class="loan-action-card">
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Address Verification</span>
<select [(ngModel)]="a.addressVerification.status">
<option value="PENDING">Pending</option>
<option value="VERIFIED">Verified</option>
<option value="FAILED">Failed</option>
</select>
</label>
<label class="inv-form-field">
<span>Office / Employment Verification</span>
<select [(ngModel)]="a.officeVerification.status">
<option value="PENDING">Pending</option>
<option value="VERIFIED">Verified</option>
<option value="FAILED">Failed</option>
</select>
</label>
@if (a.secured) {
<label class="inv-form-field">
<span>Property Verification / Valuation</span>
<select [(ngModel)]="a.propertyVerification.status">
<option value="PENDING">Pending</option>
<option value="VERIFIED">Verified</option>
<option value="FAILED">Failed</option>
</select>
</label>
<label class="inv-form-field">
<span>Property Valuation (₹)</span>
<input type="number" [(ngModel)]="a.propertyValuation" />
</label>
}
</div>
<div class="loan-kv-grid" style="padding: 0 0 14px">
<div class="loan-kv-field">
<dt>Agent Report</dt>
<dd>{{ a.agentReportUploaded ? a.agentReportFileName : 'Not uploaded yet' }}</dd>
</div>
</div>
@if (!a.agentReportUploaded) {
<div class="loan-action-row">
<label class="pg-btn pg-btn--ghost" style="cursor: pointer">
Upload Agent Report
<input
type="file"
hidden
accept=".pdf,.jpg,.jpeg,.png"
(change)="onAgentReportFileSelected($event, a)"
/>
</label>
</div>
}
<div class="loan-action-row">
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="!fiComplete(a) || !a.agentReportUploaded"
(click)="completeFi(a)"
>
Record FI &amp; Proceed to Personal Discussion
</button>
</div>
</div>
</div>
}
<!-- ===================== PERSONAL DISCUSSION ===================== -->
@if (a.currentStage === 'PERSONAL_DISCUSSION') {
<div class="pg-card">
<div class="pos-section-label">Personal Discussion with Manager</div>
<div class="loan-action-card">
<label class="inv-form-field">
<span>Discrepancy (if any)</span>
<select [(ngModel)]="discrepancySelection">
<option value="NONE">No Discrepancy</option>
<option value="DOCUMENT_DISCREPANCY">Document Discrepancy</option>
<option value="NAME_MISMATCH">Name Mismatch</option>
<option value="OFFICE_VERIFICATION_FAILED">Office Verification Failed</option>
<option value="PROPERTY_DISCREPANCY">Property Discrepancy</option>
<option value="FI_REPORT_DISCREPANCY">FI Report Discrepancy</option>
<option value="FAKE_DECLARATION">Fake Declaration Suspected</option>
</select>
</label>
<label class="inv-form-field">
<span>Discussion Notes</span>
<input type="text" [(ngModel)]="actionRemarks" placeholder="Notes from discussion with manager" />
</label>
<div class="loan-action-row">
@if (discrepancySelection === 'NONE') {
<button type="button" class="pg-btn pg-btn--success" (click)="clearPersonalDiscussion(a)">
Clear, Take Declaration &amp; Proceed to Loan Committee
</button>
} @else {
<button type="button" class="pg-btn pg-btn--warn" (click)="flagPersonalDiscussion(a)">
Flag Discrepancy — Hold Application
</button>
}
</div>
</div>
</div>
}
<!-- ===================== LOAN COMMITTEE APPROVAL ===================== -->
@if (a.currentStage === 'CREDIT_COMMITTEE') {
<div class="pg-card">
<div class="pos-section-label">Loan Committee Approval — {{ sanctioningAuthorityOf(a) }}</div>
<div class="loan-action-card">
<p class="pg-sub npa-report-note">Reports Required — present below reports, then complete.</p>
<div class="loan-checklist">
<label class="loan-checklist-item">
<input type="checkbox" [ngModel]="a.cicReportUploaded" disabled /> CIBIL / Credit Bureau Report
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.backgroundCheckDone" /> Background Check
</label>
@if (a.secured) {
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.cersaiSearchDone" /> CSE — CERSAI Search Enquiry
</label>
}
<label class="loan-checklist-item">
<input type="checkbox" [ngModel]="a.aadhaarVerified && a.panVerified" disabled /> OVD
</label>
<label class="loan-checklist-item">
<input type="checkbox" [ngModel]="a.incomeProofSubmitted" disabled /> Income Proof
</label>
<label class="loan-checklist-item">
<input type="checkbox" [ngModel]="fiComplete(a)" disabled /> FI Report
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.managerMeetingDone" /> Manager Meeting
</label>
<label class="loan-checklist-item">
<input type="checkbox" [ngModel]="a.isShareMember" disabled /> Share Member Approval
</label>
@if (a.secured) {
<label class="loan-checklist-item">
<input type="checkbox" [ngModel]="a.propertyValuation > 0" disabled /> Property Valuation
</label>
}
</div>
<label class="inv-form-field">
<span>Committee Remarks</span>
<input type="text" [(ngModel)]="actionRemarks" placeholder="Remarks" />
</label>
<div class="loan-action-row">
<button
type="button"
class="pg-btn pg-btn--success"
[disabled]="!committeeReportsReady(a)"
(click)="approveStage(a, 'creditCommitteeApproval', 'Sanctioning Authority')"
>
Approve
</button>
<button
type="button"
class="pg-btn pg-btn--danger"
(click)="rejectStage(a, 'creditCommitteeApproval', 'Sanctioning Authority')"
>
Reject
</button>
</div>
</div>
</div>
}
<!-- ===================== RISK APPROVAL ===================== -->
@if (a.currentStage === 'RISK_APPROVAL') {
<div class="pg-card">
<div class="pos-section-label">Risk Approval — Verification Checklist</div>
<div class="loan-action-card">
<div class="loan-checklist">
<label class="loan-checklist-item">
<input type="checkbox" [ngModel]="a.addressVerification.status === 'VERIFIED'" disabled /> ID / Address
Verified
</label>
<label class="loan-checklist-item">
<input type="checkbox" [ngModel]="a.incomeProofSubmitted" disabled /> Income Proof Verified
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.previousLoanDeclarationDone" /> Previous Loan Declaration
</label>
@if (a.secured) {
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.propertySearchReportDone" /> Property Search Report
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.valuationReportConfirmed" /> Valuation Report
</label>
}
<label class="loan-checklist-item">
<input type="checkbox" [ngModel]="fiComplete(a)" disabled /> FI Report
</label>
<label class="loan-checklist-item">
<input type="checkbox" [ngModel]="a.backgroundCheckDone" disabled /> Personal Background Check
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.guarantorRequired" /> Guarantor Required
</label>
</div>
@if (a.guarantorRequired) {
<label class="inv-form-field">
<span>Guarantor Name</span>
<input type="text" [(ngModel)]="a.guarantorName" />
</label>
}
<label class="inv-form-field">
<span>Risk Remarks</span>
<input
type="text"
[(ngModel)]="actionRemarks"
placeholder="ID/address, income adequacy, previous-loan declaration"
/>
</label>
<div class="loan-action-row">
<button
type="button"
class="pg-btn pg-btn--success"
[disabled]="!riskChecklistReady(a)"
(click)="approveStage(a, 'riskApproval', 'Risk Approval')"
>
Approve
</button>
<button
type="button"
class="pg-btn pg-btn--danger"
(click)="rejectStage(a, 'riskApproval', 'Risk Approval')"
>
Reject
</button>
</div>
</div>
</div>
}
<!-- ===================== LEGAL APPROVAL ===================== -->
@if (a.currentStage === 'LEGAL_APPROVAL') {
<div class="pg-card">
<div class="pos-section-label">Legal Approval — Fees &amp; A/c Opened By</div>
<div class="loan-action-card">
<div class="loan-checklist">
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.feesAndAccountOpened" /> Fees &amp; A/c Opening Done
</label>
@if (a.secured) {
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.mortgageCreated" /> Mortgage Created
</label>
}
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.legalStampDone" /> Legal Stamp Done
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.fileFilingDone" /> File Filing Done
</label>
</div>
<label class="inv-form-field">
<span>Agreement Signing Slot</span>
<input type="text" [(ngModel)]="a.agreementSigningSlot" placeholder="DD-MM-YYYY" />
</label>
<label class="inv-form-field">
<span>Legal Remarks</span>
<input type="text" [(ngModel)]="actionRemarks" placeholder="Remarks" />
</label>
<div class="loan-action-row">
<button
type="button"
class="pg-btn pg-btn--success"
[disabled]="!legalChecklistDone(a)"
(click)="approveStage(a, 'legalApproval', 'Legal Approval')"
>
Approve &amp; Allot Signing Slot
</button>
<button
type="button"
class="pg-btn pg-btn--danger"
(click)="rejectStage(a, 'legalApproval', 'Legal Approval')"
>
Reject
</button>
</div>
</div>
</div>
}
<!-- ===================== CUSTOMER SIGNING SLOT ===================== -->
@if (a.currentStage === 'CUSTOMER_SIGNING') {
<div class="pg-card">
<div class="pos-section-label">Customer Signing Slot — {{ a.agreementSigningSlot }}</div>
<div class="loan-action-card">
<p>Loan Agreement and Sanction Letter ready for the customer's acknowledgement and signature.</p>
<div class="loan-action-row">
<button type="button" class="pg-btn pg-btn--primary" (click)="markSigned(a)">
Mark Signed by Customer
</button>
</div>
</div>
</div>
}
<!-- ===================== CHEQUE RECEIVED / ACH SIGN ===================== -->
@if (a.currentStage === 'CHEQUE_ACH') {
<div class="pg-card">
<div class="pos-section-label">Cheque Received / ACH Sign</div>
<div class="loan-action-card">
<div class="loan-checklist">
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.chequeReceived" /> Cheque Received
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.achSigned" /> ACH Mandate Signed
</label>
</div>
<div class="loan-action-row">
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="!a.chequeReceived || !a.achSigned"
(click)="confirmChequeAndProceed(a)"
>
Confirm &amp; Proceed to Post-Disbursement Check
</button>
</div>
</div>
</div>
}
<!-- ===================== POST-DISBURSEMENT CHECK ===================== -->
@if (a.currentStage === 'POST_CHECK') {
<div class="pg-card">
<div class="pos-section-label">Post-Disbursement Check — Final Compliance Gate</div>
<div class="loan-action-card">
<p class="pg-sub npa-report-note">Document Signing by:</p>
<div class="loan-checklist">
<label class="loan-checklist-item"> <input type="checkbox" [(ngModel)]="a.signedByAgent" /> Agent </label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.signedByManager" /> Manager
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.signedByCommittee" /> Committee
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.signedByRiskApproval" /> Risk Approval
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.signedByLegalApproval" /> Legal Approval
</label>
</div>
<label class="inv-form-field">
<span>Remarks</span>
<input type="text" [(ngModel)]="actionRemarks" placeholder="Remarks" />
</label>
<div class="loan-action-row">
<button
type="button"
class="pg-btn pg-btn--success"
[disabled]="!postCheckSignaturesDone(a)"
(click)="approveStage(a, 'postCheckApproval', 'Post-Disbursement Check')"
>
Clear for Disbursement
</button>
<button type="button" class="pg-btn pg-btn--warn" (click)="exceptionApprove(a)">
Approve on Exception
</button>
<button
type="button"
class="pg-btn pg-btn--danger"
(click)="rejectStage(a, 'postCheckApproval', 'Post-Disbursement Check')"
>
Reject
</button>
</div>
</div>
</div>
}
<!-- ===================== DISBURSEMENT ===================== -->
@if (a.currentStage === 'DISBURSED') {
<div class="pg-card">
<div class="pos-section-label">Ready for Disbursement</div>
<div class="loan-action-card">
<label class="inv-form-field">
<span>Disbursement Mode</span>
<select [(ngModel)]="disbursementMode">
<option value="Account Credit">Account Credit</option>
<option value="NEFT">NEFT</option>
<option value="RTGS">RTGS</option>
</select>
</label>
<p class="pg-sub npa-report-note">
Disbursing also confirms EMI Activation for this account in the same step.
</p>
<div class="loan-action-row">
<button type="button" class="pg-btn pg-btn--success" (click)="disburseNow(a)">
Disburse Now &amp; Confirm EMI Activation
</button>
</div>
</div>
</div>
}
}
<div class="pg-card">
<div class="pos-section-label">Approval Summary</div>
<div class="loan-approval-summary">
<div
class="loan-approval-tile"
[class.loan-approval-tile--approved]="
a.creditCommitteeApproval.decision === 'APPROVED' ||
a.creditCommitteeApproval.decision === 'EXCEPTION_APPROVED'
"
[class.loan-approval-tile--rejected]="a.creditCommitteeApproval.decision === 'REJECTED'"
[class.loan-approval-tile--pending]="a.creditCommitteeApproval.decision === 'PENDING'"
>
<span class="loan-approval-tile__role">Loan Committee</span>
<span class="loan-approval-tile__decision">{{ a.creditCommitteeApproval.decision }}</span>
<span>{{ a.creditCommitteeApproval.approverName }} · {{ a.creditCommitteeApproval.date }}</span>
<span>{{ a.creditCommitteeApproval.remarks }}</span>
</div>
<div
class="loan-approval-tile"
[class.loan-approval-tile--approved]="
a.riskApproval.decision === 'APPROVED' || a.riskApproval.decision === 'EXCEPTION_APPROVED'
"
[class.loan-approval-tile--rejected]="a.riskApproval.decision === 'REJECTED'"
[class.loan-approval-tile--pending]="a.riskApproval.decision === 'PENDING'"
>
<span class="loan-approval-tile__role">Risk Approval</span>
<span class="loan-approval-tile__decision">{{ a.riskApproval.decision }}</span>
<span>{{ a.riskApproval.approverName }} · {{ a.riskApproval.date }}</span>
<span>{{ a.riskApproval.remarks }}</span>
</div>
<div
class="loan-approval-tile"
[class.loan-approval-tile--approved]="
a.legalApproval.decision === 'APPROVED' || a.legalApproval.decision === 'EXCEPTION_APPROVED'
"
[class.loan-approval-tile--rejected]="a.legalApproval.decision === 'REJECTED'"
[class.loan-approval-tile--pending]="a.legalApproval.decision === 'PENDING'"
>
<span class="loan-approval-tile__role">Legal Approval</span>
<span class="loan-approval-tile__decision">{{ a.legalApproval.decision }}</span>
<span>{{ a.legalApproval.approverName }} · {{ a.legalApproval.date }}</span>
<span>{{ a.legalApproval.remarks }}</span>
</div>
<div
class="loan-approval-tile"
[class.loan-approval-tile--approved]="
a.postCheckApproval.decision === 'APPROVED' || a.postCheckApproval.decision === 'EXCEPTION_APPROVED'
"
[class.loan-approval-tile--rejected]="a.postCheckApproval.decision === 'REJECTED'"
[class.loan-approval-tile--pending]="a.postCheckApproval.decision === 'PENDING'"
>
<span class="loan-approval-tile__role">Post-Disbursement Check</span>
<span class="loan-approval-tile__decision">{{ a.postCheckApproval.decision }}</span>
<span>{{ a.postCheckApproval.approverName }} · {{ a.postCheckApproval.date }}</span>
<span>{{ a.postCheckApproval.remarks }}</span>
</div>
</div>
</div>
<div class="pg-card">
<div class="pos-section-label">Application Journey — Date-wise Audit Trail</div>
<div class="npa-timeline" style="padding: 4px 18px 18px">
@for (h of a.stageHistory; track h.date + h.stage + h.outcome) {
<div [class]="historyToneClass(h.outcome)">
<span class="npa-timeline-item__icon">{{ stepMarker(a, h.stage) }}</span>
<div class="npa-timeline-item__body">
<div class="npa-timeline-item__row">
<span class="npa-timeline-item__type">{{ stageLabels[h.stage] }} — {{ h.outcome }}</span>
<span class="npa-timeline-item__date">{{ h.date }}</span>
</div>
<p class="npa-timeline-item__remarks">{{ h.remarks }}</p>
<span class="npa-timeline-item__channel">{{ h.actionBy }}</span>
</div>
</div>
} @empty {
<p class="pg-empty">No activity recorded yet.</p>
}
</div>
</div>
} @else {
<div class="pg-card">
<div class="pos-section-label">Application Not Found</div>
<div class="loan-action-card">
<button type="button" class="pg-btn pg-btn--ghost" (click)="goBack()">‹ Back to Pipeline</button>
</div>
</div>
}
</div>
@@ -0,0 +1,418 @@
/**
* 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, OnDestroy, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { Subscription } from 'rxjs';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import {
ApprovalField,
BRANCHES,
BranchMaster,
DiscrepancyType,
DISCREPANCY_LABELS,
EmiScheduleRow,
LoanApplication,
LOAN_APPLICATIONS,
OriginationStage,
STAGE_LABELS,
STAGE_ORDER,
STATUS_LABELS,
allotShareMembership,
buildEmiSchedule,
calcEligibleAmount,
calculateEmi,
checkCreditScore,
cibilBand,
CIC_BUREAUS,
clearPersonalDiscussionAndProceed,
confirmChequeAch,
decideApproval,
disburseApplication,
flagDiscrepancy,
isRevolvingProduct,
markCustomerSigned,
progressPct,
pushFieldInquiry,
resolveDiscrepancyAndProceed,
sanctioningAuthority,
setMonthlyIncome,
stageBadgeClass,
stageIndex,
proceedToNextStage,
recordVerification,
todayDDMMYYYY,
uploadAgentReport,
uploadCicReport
} from '../loan-origination-data';
/**
* A single application's full sanctioning journey — the stage stepper, the current stage's
* action card (checklist / CIC upload / Field Investigation / Committee-Risk-Legal approval /
* signing / disbursement), and the complete date-wise audit trail. Every action here mutates the
* SAME LoanApplication object the Pipeline list reads, so the two screens can never disagree.
*/
@Component({
selector: 'mifosx-application-detail',
templateUrl: './application-detail.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 ApplicationDetailComponent implements OnInit, OnDestroy {
readonly stageOrder = STAGE_ORDER;
readonly stageLabels = STAGE_LABELS;
readonly statusLabels = STATUS_LABELS;
readonly discrepancyLabels = DISCREPANCY_LABELS;
asOnDate = todayDDMMYYYY();
application: LoanApplication | undefined;
/** Shared remarks box for whichever action card is currently on screen. */
actionRemarks = '';
discrepancySelection: DiscrepancyType = 'NONE';
disbursementMode = 'Account Credit';
/** KYC stage's Monthly Income entry field — blank until the officer keys it in. */
monthlyIncomeInput: number | null = null;
currentUserName = 'Branch User';
/** True for a Head Office login, which can open any branch's application — a branch-level
* login is locked to its own branch, the same split every other report in the app uses. */
isHeadOffice = false;
private ownBranchCode: string | null = null;
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly auth = inject(AuthenticationService);
private sub?: Subscription;
ngOnInit(): void {
const creds = this.auth.getCredentials();
this.currentUserName = creds?.username || 'Branch User';
this.isHeadOffice = this.auth.isHeadOfficeUser();
if (!this.isHeadOffice) {
const officeName = creds?.officeName || '';
this.ownBranchCode =
BRANCHES.find((b: BranchMaster) => officeName.toLowerCase().includes(b.name.toLowerCase()))?.code || null;
}
this.sub = this.route.paramMap.subscribe((params) => {
const applicationNo = params.get('applicationNo');
const found = LOAN_APPLICATIONS.find((a) => a.applicationNo === applicationNo);
// A branch-level login can only open applications belonging to its own branch — treated
// exactly like "not found" rather than a distinct "access denied", so it doesn't reveal
// that a differently-branched application exists at all.
this.application = found && !this.isHeadOffice && found.branchCode !== this.ownBranchCode ? undefined : found;
this.actionRemarks = '';
this.discrepancySelection = 'NONE';
this.monthlyIncomeInput = this.application?.monthlyIncome || null;
});
}
ngOnDestroy(): void {
this.sub?.unsubscribe();
}
goBack(): void {
this.router.navigate(['/loan-origination/pipeline']);
}
// -------------------- Stepper --------------------
stepStatusClass(a: LoanApplication, stage: OriginationStage): string {
const idx = stageIndex(stage);
const curIdx = stageIndex(a.currentStage);
if (stage === a.currentStage && a.overallStatus === 'REJECTED') {
return 'loan-step loan-step--rejected';
}
if (stage === a.currentStage && a.overallStatus === 'ON_HOLD') {
return 'loan-step loan-step--hold';
}
if (idx < curIdx || (idx === curIdx && a.overallStatus === 'DISBURSED')) {
return 'loan-step loan-step--done';
}
if (idx === curIdx) {
return 'loan-step loan-step--current';
}
return 'loan-step';
}
stepMarker(a: LoanApplication, stage: OriginationStage): string {
const idx = stageIndex(stage);
const curIdx = stageIndex(a.currentStage);
if (idx < curIdx || (idx === curIdx && a.overallStatus === 'DISBURSED')) {
return '✓';
}
return String(idx + 1);
}
progress(a: LoanApplication): number {
return progressPct(a);
}
/** Colours an audit-trail entry's icon badge by what actually happened — a rejection/hold
* reads as an alert, everything else (approvals, verifications, uploads, signings) reads
* as positive progress. */
historyToneClass(outcome: string): string {
if (/reject|hold/i.test(outcome)) {
return 'npa-timeline-item npa-timeline-item--alert';
}
return 'npa-timeline-item npa-timeline-item--positive';
}
badgeClass(a: LoanApplication): string {
return stageBadgeClass(a);
}
cibilBandOf(score: number): string {
return cibilBand(score);
}
readonly cicBureaus = CIC_BUREAUS;
checkCreditScoreNow(a: LoanApplication): void {
checkCreditScore(a, this.currentUserName);
}
sanctioningAuthorityOf(a: LoanApplication): string {
return sanctioningAuthority(a.requestedAmount);
}
// -------------------- Generic stage-advance actions --------------------
proceed(a: LoanApplication, remarks: string): void {
proceedToNextStage(a, this.currentUserName, remarks);
}
// -------------------- KYC & Documents --------------------
/** Live preview of what Eligible Loan Amount would be for whatever's currently typed into the
* Monthly Income box — before the officer actually commits it via "Assess Income". */
eligiblePreview(a: LoanApplication): number {
return calcEligibleAmount(this.monthlyIncomeInput || 0, a.productCode);
}
setIncomeNow(a: LoanApplication): void {
if (!this.monthlyIncomeInput || this.monthlyIncomeInput <= 0) {
return;
}
setMonthlyIncome(a, this.monthlyIncomeInput, this.currentUserName);
}
allKycDone(a: LoanApplication): boolean {
const base =
a.aadhaarVerified &&
a.panVerified &&
a.aadhaarFingerprintCaptured &&
a.incomeProofSubmitted &&
a.bankStatementSubmitted &&
a.electricityBillSubmitted &&
a.monthlyIncome > 0;
return a.secured ? base && a.propertyDetailsSubmitted && a.taxReceiptSubmitted : base;
}
// -------------------- Field Investigation --------------------
fiComplete(a: LoanApplication): boolean {
const addressOk = a.addressVerification.status === 'VERIFIED';
const officeOk = a.officeVerification.status === 'VERIFIED';
const propertyOk = !a.secured || a.propertyVerification.status === 'VERIFIED';
return addressOk && officeOk && propertyOk;
}
completeFi(a: LoanApplication): void {
const agent = 'Field Verification Team';
const keys: Array<'addressVerification' | 'officeVerification' | 'propertyVerification'> = [
'addressVerification',
'officeVerification',
'propertyVerification'
];
for (const key of keys) {
if (a[key].status !== 'PENDING') {
recordVerification(
a,
key,
a[key].status,
agent,
a[key].remarks === '—' ? `${key} recorded on FI visit.` : a[key].remarks
);
}
}
this.proceed(a, 'Field Investigation completed — Agent Report filed.');
}
// -------------------- Personal Discussion --------------------
clearPersonalDiscussion(a: LoanApplication): void {
clearPersonalDiscussionAndProceed(
a,
this.currentUserName,
this.actionRemarks.trim() || 'No discrepancy found — cleared to proceed to Loan Committee.'
);
this.actionRemarks = '';
}
flagPersonalDiscussion(a: LoanApplication): void {
const notes =
this.actionRemarks.trim() ||
`${this.discrepancyLabels[this.discrepancySelection]} flagged during personal discussion — held pending resolution.`;
flagDiscrepancy(a, this.discrepancySelection, this.currentUserName, notes);
this.actionRemarks = '';
this.discrepancySelection = 'NONE';
}
resolveDiscrepancy(a: LoanApplication): void {
resolveDiscrepancyAndProceed(
a,
this.currentUserName,
this.actionRemarks.trim() || 'Discrepancy resolved on further review — cleared to proceed.'
);
this.actionRemarks = '';
}
// -------------------- Share Membership --------------------
allotMembership(a: LoanApplication): void {
allotShareMembership(a, this.currentUserName);
}
// -------------------- Approvals (Credit Committee / Risk / Legal / Post-Check) --------------------
approveStage(a: LoanApplication, field: ApprovalField, approverRole: string): void {
decideApproval(
a,
field,
'APPROVED',
this.currentUserName,
approverRole,
this.actionRemarks.trim() || 'Reviewed and approved.'
);
this.actionRemarks = '';
}
rejectStage(a: LoanApplication, field: ApprovalField, approverRole: string): void {
decideApproval(
a,
field,
'REJECTED',
this.currentUserName,
approverRole,
this.actionRemarks.trim() || 'Declined on review.'
);
this.actionRemarks = '';
}
exceptionApprove(a: LoanApplication): void {
decideApproval(
a,
'postCheckApproval',
'EXCEPTION_APPROVED',
this.currentUserName,
'Post-Disbursement Check',
this.actionRemarks.trim() || 'Approved on exception — minor gap noted and accepted.'
);
this.actionRemarks = '';
}
legalChecklistDone(a: LoanApplication): boolean {
return a.feesAndAccountOpened && (!a.secured || a.mortgageCreated) && a.legalStampDone && a.fileFilingDone;
}
committeeReportsReady(a: LoanApplication): boolean {
return (
a.cicReportUploaded &&
a.backgroundCheckDone &&
(!a.secured || a.cersaiSearchDone) &&
a.aadhaarVerified &&
a.panVerified &&
a.incomeProofSubmitted &&
this.fiComplete(a) &&
a.managerMeetingDone
);
}
riskChecklistReady(a: LoanApplication): boolean {
return (
a.addressVerification.status === 'VERIFIED' &&
a.incomeProofSubmitted &&
a.previousLoanDeclarationDone &&
(!a.secured || (a.propertySearchReportDone && a.valuationReportConfirmed)) &&
this.fiComplete(a) &&
a.backgroundCheckDone
);
}
postCheckSignaturesDone(a: LoanApplication): boolean {
return (
a.signedByAgent && a.signedByManager && a.signedByCommittee && a.signedByRiskApproval && a.signedByLegalApproval
);
}
// -------------------- CIC Upload --------------------
onCicFileSelected(event: Event, a: LoanApplication): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
uploadCicReport(a, file.name, this.currentUserName);
input.value = '';
}
pushFieldInquiryNow(a: LoanApplication): void {
pushFieldInquiry(a, 'Field Verification Team', this.currentUserName);
}
onAgentReportFileSelected(event: Event, a: LoanApplication): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
uploadAgentReport(a, file.name, this.currentUserName);
input.value = '';
}
// -------------------- Signing / Cheque-ACH / Disbursement --------------------
markSigned(a: LoanApplication): void {
markCustomerSigned(a, a.applicantName);
}
confirmChequeAndProceed(a: LoanApplication): void {
confirmChequeAch(a, this.currentUserName);
}
disburseNow(a: LoanApplication): void {
disburseApplication(a, this.currentUserName, this.disbursementMode);
}
// -------------------- EMI Repayment Schedule (once disbursed) --------------------
isRevolving(a: LoanApplication): boolean {
return isRevolvingProduct(a.productCode);
}
emiAmountFor(a: LoanApplication): number {
return calculateEmi(a.requestedAmount, a.interestRate, a.tenureMonths);
}
emiScheduleFor(a: LoanApplication): EmiScheduleRow[] {
return buildEmiSchedule(a.requestedAmount, a.interestRate, a.tenureMonths, a.disbursementDate);
}
totalInterestFor(a: LoanApplication): number {
return this.emiScheduleFor(a).reduce((s, r) => s + r.interestComponent, 0);
}
totalPayableFor(a: LoanApplication): number {
return a.requestedAmount + this.totalInterestFor(a);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,46 @@
/**
* 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 { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { LoanPipelineComponent } from './loan-pipeline/loan-pipeline.component';
import { ApplicationDetailComponent } from './application-detail/application-detail.component';
const routes: Routes = [
Route.withShell([
{
path: 'loan-origination',
data: { title: 'Loan Origination', breadcrumb: 'Loan Origination' },
children: [
{
path: '',
redirectTo: 'pipeline',
pathMatch: 'full'
},
{
path: 'pipeline',
component: LoanPipelineComponent,
data: { title: 'Loan Origination Pipeline', breadcrumb: 'Pipeline' }
},
{
path: 'application/:applicationNo',
component: ApplicationDetailComponent,
data: { title: 'Loan Application', breadcrumb: 'Application' }
}
]
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class LoanOriginationRoutingModule {}
@@ -0,0 +1,322 @@
/**
* 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/.
*/
// Loan Origination-specific styling layered on top of ../cashier/cashier-shared.scss (pg-*,
// cctr-*) and ../npa/npa-shared.scss (npa-timeline-* — reused as-is for the application's stage
// history/audit trail, since it's the same generic "date-led event log" primitive already built
// for the NPA Recovery Journey popup, not NPA-specific).
// EMI Repayment Schedule can run to 240 rows (a 20-year Housing Loan) — scrolls within a fixed
// height with the header pinned, rather than pushing the rest of the page far down.
.loan-emi-table-scroll {
max-height: 420px;
overflow-y: auto;
thead th {
position: sticky;
top: 0;
z-index: 1;
background: #fff;
}
}
.loan-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
&--progress {
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
&--hold {
background: #fffbeb;
color: #b45309;
border: 1px solid #fde68a;
}
&--rejected {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
&--disbursed {
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
}
}
.pg-btn--success {
background: linear-gradient(135deg, #22c55e, #15803d);
color: #fff;
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.pg-btn--danger {
background: #fff;
border-color: #fecaca;
color: #dc2626;
&:hover {
background: #fef2f2;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.pg-btn--warn {
background: #fff;
border-color: #fde68a;
color: #b45309;
&:hover {
background: #fffbeb;
}
}
// ---------------------------------------------------------------------------
// Stage stepper — the 12-stage sanctioning pipeline, drawn as a connected row of
// dots so an application's progress reads at a glance.
// ---------------------------------------------------------------------------
.loan-stepper {
display: flex;
align-items: flex-start;
overflow-x: auto;
padding: 22px 18px 16px;
}
.loan-step {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
min-width: 90px;
position: relative;
&__connector {
position: absolute;
top: 15px;
left: -50%;
width: 100%;
height: 2px;
background: #e2e8f0;
z-index: 0;
}
&:first-child &__connector {
display: none;
}
&__dot {
position: relative;
z-index: 1;
width: 32px;
height: 32px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
background: #f1f5f9;
color: #94a3b8;
border: 2px solid #e2e8f0;
}
&__label {
margin-top: 8px;
font-size: 10.5px;
text-align: center;
color: #64748b;
font-weight: 600;
line-height: 1.3;
max-width: 92px;
}
&--done {
.loan-step__dot {
background: #15803d;
border-color: #15803d;
color: #fff;
}
.loan-step__connector {
background: #86efac;
}
.loan-step__label {
color: #15803d;
}
}
&--current {
.loan-step__dot {
background: #4338ca;
border-color: #4338ca;
color: #fff;
box-shadow: 0 0 0 4px rgb(67 56 202 / 15%);
}
.loan-step__label {
color: #4338ca;
font-weight: 800;
}
}
&--rejected {
.loan-step__dot {
background: #dc2626;
border-color: #dc2626;
color: #fff;
}
.loan-step__label {
color: #dc2626;
}
}
&--hold {
.loan-step__dot {
background: #d97706;
border-color: #d97706;
color: #fff;
}
.loan-step__label {
color: #d97706;
}
}
}
// ---------------------------------------------------------------------------
// Action card — the current stage's checklist/inputs and Approve/Reject buttons.
// ---------------------------------------------------------------------------
.loan-action-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 16px 18px;
margin: 0 16px 16px;
}
.loan-checklist {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 14px;
}
.loan-checklist-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: #1e293b;
// stylelint-disable-next-line selector-no-qualifying-type
input[type='checkbox'] {
width: 16px;
height: 16px;
accent-color: #4338ca;
}
}
.loan-action-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 12px;
}
.loan-kv-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px 20px;
padding: 18px;
}
.loan-kv-field {
display: flex;
flex-direction: column;
gap: 3px;
dt {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #64748b;
}
dd {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #0d1b2a;
font-variant-numeric: tabular-nums;
}
}
.loan-approval-summary {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
padding: 4px 18px 18px;
}
.loan-approval-tile {
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 4px;
&__role {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
}
&__decision {
font-size: 13px;
font-weight: 700;
}
&--approved .loan-approval-tile__decision {
color: #15803d;
}
&--rejected .loan-approval-tile__decision {
color: #dc2626;
}
&--pending .loan-approval-tile__decision {
color: #94a3b8;
}
}
@@ -0,0 +1,24 @@
/**
* 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 { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { LoanOriginationRoutingModule } from './loan-origination-routing.module';
import { LoanPipelineComponent } from './loan-pipeline/loan-pipeline.component';
import { ApplicationDetailComponent } from './application-detail/application-detail.component';
@NgModule({
imports: [
SharedModule,
LoanOriginationRoutingModule,
LoanPipelineComponent,
ApplicationDetailComponent
]
})
export class LoanOriginationModule {}
@@ -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);
}
}