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.
419 lines
13 KiB
TypeScript
419 lines
13 KiB
TypeScript
/**
|
|
* Copyright since 2025 Mifos Initiative
|
|
*
|
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
*/
|
|
|
|
import { Component, 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);
|
|
}
|
|
}
|