From 5cd9998cfc3aef28c8fc8e5232ea0cc0ba154e66 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Sun, 2 Aug 2026 14:22:32 +0530 Subject: [PATCH] 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. --- src/app/app.module.ts | 2 + src/app/core/shell/shell.component.html | 41 + .../application-detail.component.html | 878 ++++++++++ .../application-detail.component.ts | 418 +++++ .../loan-origination/loan-origination-data.ts | 1510 +++++++++++++++++ .../loan-origination-routing.module.ts | 46 + .../loan-origination-shared.scss | 322 ++++ .../loan-origination.module.ts | 24 + .../loan-pipeline.component.html | 558 ++++++ .../loan-pipeline/loan-pipeline.component.ts | 675 ++++++++ 10 files changed, 4474 insertions(+) create mode 100644 src/app/loan-origination/application-detail/application-detail.component.html create mode 100644 src/app/loan-origination/application-detail/application-detail.component.ts create mode 100644 src/app/loan-origination/loan-origination-data.ts create mode 100644 src/app/loan-origination/loan-origination-routing.module.ts create mode 100644 src/app/loan-origination/loan-origination-shared.scss create mode 100644 src/app/loan-origination/loan-origination.module.ts create mode 100644 src/app/loan-origination/loan-pipeline/loan-pipeline.component.html create mode 100644 src/app/loan-origination/loan-pipeline/loan-pipeline.component.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index d970dca36..0dac0a1d2 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -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, diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 9e594590a..a3bdbc9f7 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -350,6 +350,10 @@ NPA + + Loan Origination + + Advances @@ -442,6 +446,10 @@ NPA + + Loan Origination + + + +
+ +
+
Loan Origination
+ +
+ +
+
Reports
+ + + + +
+
+
+ + + + +
+
+
+
Branch
+
{{ a.branchName }}
+
+
+
Channel
+
+ {{ a.channel === 'BC' ? a.bcAgentName : a.channel === 'SELF_APPLY' ? 'Self-Apply (Digital)' : 'Branch' }} +
+
+
+
Customer Type
+
{{ a.customerType === 'EXISTING' ? 'Existing Customer' : 'New Customer' }}
+
+
+
Tenure / Rate
+
{{ a.tenureMonths }} months @ {{ a.interestRate }}%
+
+
+
Monthly Income / Eligible Amount
+
₹{{ a.monthlyIncome | number: '1.0-0' }} / ₹{{ a.eligibleLoanAmount | number: '1.0-0' }}
+
+
+
Sanctioning Authority
+
{{ sanctioningAuthorityOf(a) }}
+
+
+
Share Member
+
+ @if (a.isShareMember) { + Yes — {{ a.shareCertNo }} (since {{ a.shareMemberSince }}) + } @else { + Not a Member + + } +
+
+ @if (a.coBorrowerRequired) { +
+
Co-Borrower
+
{{ a.coBorrowerName }}
+
+ } + @if (a.guarantorRequired) { +
+
Guarantor
+
{{ a.guarantorName }}
+
+ } +
+
+ +
+ +
+ @for (s of stageOrder; track s) { +
+ + {{ stepMarker(a, s) }} + {{ stageLabels[s] }} +
+ } +
+
+ + @if (a.overallStatus === 'REJECTED') { +
+ +

See the approval summary below for the recorded reason.

+
+ } @else if (a.overallStatus === 'ON_HOLD') { +
+ +
+

{{ a.personalDiscussionNotes }}

+ +
+ +
+
+
+ } @else if (a.overallStatus === 'DISBURSED') { +
+ +
+
+
Disbursement Date
+
{{ a.disbursementDate }}
+
+
+
Mode
+
{{ a.disbursementMode }}
+
+
+
ACH Confirmation
+
{{ a.achConfirmationRef }}
+
+
+
EMI Activation Confirmation
+
{{ a.emiActivationConfirmed ? 'Confirmed on ' + a.emiActivationDate : 'Pending' }}
+
+
+
+ +
+ + @if (isRevolving(a)) { +

+ {{ a.productName }} is a revolving/renewable limit — repaid on demand and renewed annually, not on a fixed + EMI schedule. +

+ } @else { +
+
+ Loan Amount + ₹{{ a.requestedAmount | number: '1.0-0' }} + {{ a.tenureMonths }} months @ {{ a.interestRate }}% +
+
+ Monthly EMI + ₹{{ emiAmountFor(a) | number: '1.0-0' }} + Reducing balance +
+
+ Total Interest Payable + ₹{{ totalInterestFor(a) | number: '1.0-0' }} + over the full tenure +
+
+ Total Amount Payable + ₹{{ totalPayableFor(a) | number: '1.0-0' }} + principal + interest +
+
+
+ + + + + + + + + + + + + + @for (row of emiScheduleFor(a); track row.installmentNo) { + + + + + + + + + + } + +
Inst. NoDue DateOpening BalanceEMIPrincipalInterestClosing Balance
{{ row.installmentNo }}{{ row.dueDate }}₹{{ row.openingBalance | number: '1.0-0' }}₹{{ row.emiAmount | number: '1.0-0' }}₹{{ row.principalComponent | number: '1.0-0' }}₹{{ row.interestComponent | number: '1.0-0' }}₹{{ row.closingBalance | number: '1.0-0' }}
+
+ } +
+ } @else { + + @if (a.currentStage === 'APPLICATION') { +
+ +
+

+ Received via + {{ a.channel === 'BC' ? a.bcAgentName : a.channel === 'SELF_APPLY' ? 'Self-Apply (Digital)' : 'Branch' }} + on {{ a.applicationDate }}. +

+
+ +
+
+
+ } + + + @if (a.currentStage === 'KYC_DOCUMENTS') { +
+ +
+
+ + + + + @if (a.secured) { + + + } + + +
+ @if (a.monthlyIncome === 0) { + +

+ Eligible Loan Amount preview: ₹{{ eligiblePreview(a) | number: '1.0-0' }} +

+
+ +
+ } @else { +
+
+
Monthly Income
+
₹{{ a.monthlyIncome | number: '1.0-0' }}
+
+
+
Eligible Loan Amount
+
₹{{ a.eligibleLoanAmount | number: '1.0-0' }}
+
+
+ } + @if (a.coBorrowerRequired) { +

Co-Borrower Required — {{ a.coBorrowerName }}

+ } +
+ +
+
+
+ } + + + @if (a.currentStage === 'CIC_CHECK') { +
+ +
+ @if (a.cibilScore === 0) { + +
+ +
+ } @else { +
+
+
Bureau
+
{{ a.cicBureau }}
+
+
+
Credit Score
+
{{ a.cibilScore }} ({{ cibilBandOf(a.cibilScore) }})
+
+
+
Report
+
{{ a.cicReportUploaded ? a.cicReportFileName : 'Not uploaded yet' }}
+
+
+
Field Inquiry
+
+ {{ + a.fiRequestPushed + ? 'Pushed to ' + a.fiAgencyAssigned + ' (' + a.fiRequestDate + ')' + : 'Not pushed yet' + }} +
+
+
+ } + @if (a.cibilScore > 0) { + @if (!a.cicReportUploaded) { +
+ +
+ } @else if (!a.fiRequestPushed) { +
+ +
+ } @else { +
+ +
+ } + } +
+
+ } + + + @if (a.currentStage === 'FIELD_INVESTIGATION') { +
+ +
+
+ + + @if (a.secured) { + + + } +
+
+
+
Agent Report
+
{{ a.agentReportUploaded ? a.agentReportFileName : 'Not uploaded yet' }}
+
+
+ @if (!a.agentReportUploaded) { +
+ +
+ } +
+ +
+
+
+ } + + + @if (a.currentStage === 'PERSONAL_DISCUSSION') { +
+ +
+ + +
+ @if (discrepancySelection === 'NONE') { + + } @else { + + } +
+
+
+ } + + + @if (a.currentStage === 'CREDIT_COMMITTEE') { +
+ +
+

Reports Required — present below reports, then complete.

+
+ + + @if (a.secured) { + + } + + + + + + @if (a.secured) { + + } +
+ +
+ + +
+
+
+ } + + + @if (a.currentStage === 'RISK_APPROVAL') { +
+ +
+
+ + + + @if (a.secured) { + + + } + + + +
+ @if (a.guarantorRequired) { + + } + +
+ + +
+
+
+ } + + + @if (a.currentStage === 'LEGAL_APPROVAL') { +
+ +
+
+ + @if (a.secured) { + + } + + +
+ + +
+ + +
+
+
+ } + + + @if (a.currentStage === 'CUSTOMER_SIGNING') { +
+ +
+

Loan Agreement and Sanction Letter ready for the customer's acknowledgement and signature.

+
+ +
+
+
+ } + + + @if (a.currentStage === 'CHEQUE_ACH') { +
+ +
+
+ + +
+
+ +
+
+
+ } + + + @if (a.currentStage === 'POST_CHECK') { +
+ +
+

Document Signing by:

+
+ + + + + +
+ +
+ + + +
+
+
+ } + + + @if (a.currentStage === 'DISBURSED') { +
+ +
+ +

+ Disbursing also confirms EMI Activation for this account in the same step. +

+
+ +
+
+
+ } + } + +
+ +
+
+ Loan Committee + {{ a.creditCommitteeApproval.decision }} + {{ a.creditCommitteeApproval.approverName }} · {{ a.creditCommitteeApproval.date }} + {{ a.creditCommitteeApproval.remarks }} +
+
+ Risk Approval + {{ a.riskApproval.decision }} + {{ a.riskApproval.approverName }} · {{ a.riskApproval.date }} + {{ a.riskApproval.remarks }} +
+
+ Legal Approval + {{ a.legalApproval.decision }} + {{ a.legalApproval.approverName }} · {{ a.legalApproval.date }} + {{ a.legalApproval.remarks }} +
+
+ Post-Disbursement Check + {{ a.postCheckApproval.decision }} + {{ a.postCheckApproval.approverName }} · {{ a.postCheckApproval.date }} + {{ a.postCheckApproval.remarks }} +
+
+
+ +
+ +
+ @for (h of a.stageHistory; track h.date + h.stage + h.outcome) { +
+ {{ stepMarker(a, h.stage) }} +
+
+ {{ stageLabels[h.stage] }} — {{ h.outcome }} + {{ h.date }} +
+

{{ h.remarks }}

+ {{ h.actionBy }} +
+
+ } @empty { +

No activity recorded yet.

+ } +
+
+ } @else { +
+ +
+ +
+
+ } + diff --git a/src/app/loan-origination/application-detail/application-detail.component.ts b/src/app/loan-origination/application-detail/application-detail.component.ts new file mode 100644 index 000000000..3066bf767 --- /dev/null +++ b/src/app/loan-origination/application-detail/application-detail.component.ts @@ -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); + } +} diff --git a/src/app/loan-origination/loan-origination-data.ts b/src/app/loan-origination/loan-origination-data.ts new file mode 100644 index 000000000..dd5f90e45 --- /dev/null +++ b/src/app/loan-origination/loan-origination-data.ts @@ -0,0 +1,1510 @@ +/** + * 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/. + */ + +// Dummy Loan Origination / Sanctioning workflow data layer — the pre-disbursement pipeline a +// real-world cooperative-bank CBS runs every application through: Channel intake → KYC/Document +// collection → CIC (Credit Information Company) check → Field Investigation → Personal Discussion +// (discrepancy resolution) → Credit Committee → Risk Approval → Legal Approval → Customer Signing +// → Cheque/ACH → Post-Disbursement Check → Disbursement. Modelled per RBI's KYC Master Direction +// (OVD-based identity verification), the mandatory Credit Information Companies check before +// sanction, and the Fair Practices Code for lenders (sanction letter, loan agreement, transparent +// terms) — plus the co-operative-bank-specific rule that a borrower must hold membership shares. +// +// Everything downstream (progress %, sanctioning authority, CIBIL band, ageing) is *derived* from +// the stored application fields via pure functions below — never duplicated by hand — and the book +// is generated with a seeded RNG (not Math.random()) so it stays identical across reloads. + +import { BRANCHES, BranchMaster } from '../npa/npa-data'; + +export { BRANCHES, BranchMaster }; + +// ------------------------------------------------------------------------- +// Stage model +// ------------------------------------------------------------------------- +export type OriginationStage = + | 'APPLICATION' + | 'KYC_DOCUMENTS' + | 'CIC_CHECK' + | 'FIELD_INVESTIGATION' + | 'PERSONAL_DISCUSSION' + | 'CREDIT_COMMITTEE' + | 'RISK_APPROVAL' + | 'LEGAL_APPROVAL' + | 'CUSTOMER_SIGNING' + | 'CHEQUE_ACH' + | 'POST_CHECK' + | 'DISBURSED'; + +export const STAGE_ORDER: OriginationStage[] = [ + 'APPLICATION', + 'KYC_DOCUMENTS', + 'CIC_CHECK', + 'FIELD_INVESTIGATION', + 'PERSONAL_DISCUSSION', + 'CREDIT_COMMITTEE', + 'RISK_APPROVAL', + 'LEGAL_APPROVAL', + 'CUSTOMER_SIGNING', + 'CHEQUE_ACH', + 'POST_CHECK', + 'DISBURSED' +]; + +export const STAGE_LABELS: Record = { + APPLICATION: 'Application Received', + KYC_DOCUMENTS: 'KYC & Document Collection', + CIC_CHECK: 'CIC / Credit Bureau Check', + FIELD_INVESTIGATION: 'Field Investigation (FI)', + PERSONAL_DISCUSSION: 'Personal Discussion with Manager', + CREDIT_COMMITTEE: 'Loan Committee Approval', + RISK_APPROVAL: 'Risk Approval', + LEGAL_APPROVAL: 'Legal Approval', + CUSTOMER_SIGNING: 'Customer Signing Slot', + CHEQUE_ACH: 'Cheque Received / ACH Sign', + POST_CHECK: 'Post-Disbursement Check', + DISBURSED: 'Disbursed' +}; + +export function stageIndex(stage: OriginationStage): number { + return STAGE_ORDER.indexOf(stage); +} + +export function nextStage(stage: OriginationStage): OriginationStage | null { + const idx = stageIndex(stage); + return idx >= 0 && idx < STAGE_ORDER.length - 1 ? STAGE_ORDER[idx + 1] : null; +} + +export type OverallStatus = 'IN_PROGRESS' | 'ON_HOLD' | 'REJECTED' | 'DISBURSED'; + +export const STATUS_LABELS: Record = { + IN_PROGRESS: 'In Progress', + ON_HOLD: 'On Hold — Discrepancy', + REJECTED: 'Rejected', + DISBURSED: 'Disbursed' +}; + +export type Channel = 'BRANCH' | 'BC' | 'SELF_APPLY'; + +export const CHANNEL_LABELS: Record = { + BRANCH: 'Branch', + BC: 'Business Correspondent (BC)', + SELF_APPLY: 'Self-Apply (Digital)' +}; + +export type CustomerType = 'EXISTING' | 'NEW'; + +/** The 4 credit information companies actually licensed by RBI under the CICRA, 2005. */ +export type CicBureau = 'CIBIL' | 'Equifax' | 'Experian' | 'CRIF High Mark'; + +export const CIC_BUREAUS: CicBureau[] = [ + 'CIBIL', + 'Equifax', + 'Experian', + 'CRIF High Mark' +]; + +export type DiscrepancyType = + | 'NONE' + | 'DOCUMENT_DISCREPANCY' + | 'NAME_MISMATCH' + | 'OFFICE_VERIFICATION_FAILED' + | 'PROPERTY_DISCREPANCY' + | 'FI_REPORT_DISCREPANCY' + | 'FAKE_DECLARATION'; + +export const DISCREPANCY_LABELS: Record = { + NONE: 'No Discrepancy', + DOCUMENT_DISCREPANCY: 'Document Discrepancy', + NAME_MISMATCH: 'Name Mismatch', + OFFICE_VERIFICATION_FAILED: 'Office Verification Failed', + PROPERTY_DISCREPANCY: 'Property Discrepancy', + FI_REPORT_DISCREPANCY: 'FI Report Discrepancy', + FAKE_DECLARATION: 'Fake Declaration Suspected' +}; + +export type ApprovalDecision = 'PENDING' | 'APPROVED' | 'REJECTED' | 'EXCEPTION_APPROVED'; + +export const APPROVAL_LABELS: Record = { + PENDING: 'Pending', + APPROVED: 'Approved', + REJECTED: 'Rejected', + EXCEPTION_APPROVED: 'Approved on Exception' +}; + +export type VerificationStatus = 'PENDING' | 'VERIFIED' | 'FAILED'; + +export interface VerificationCheck { + status: VerificationStatus; + verifiedBy: string; + verifiedDate: string; + remarks: string; +} + +export interface ApprovalRecord { + decision: ApprovalDecision; + approverName: string; + approverRole: string; + date: string; + remarks: string; +} + +export interface StageHistoryEntry { + stage: OriginationStage; + date: string; + actionBy: string; + outcome: string; + remarks: string; +} + +export interface LoanApplication { + applicationNo: string; + applicationDate: string; + applicantName: string; + branchCode: string; + branchName: string; + + channel: Channel; + /** '—' unless channel === 'BC'. */ + bcAgentName: string; + customerType: CustomerType; + + productCode: string; + productName: string; + secured: boolean; + requestedAmount: number; + tenureMonths: number; + interestRate: number; + + // Co-operative bank membership — a borrower must hold shares in the society to be sanctioned. + isShareMember: boolean; + shareCertNo: string; + shareMemberSince: string; + + // KYC / Documents (OVD-based per RBI KYC Master Direction) + aadhaarVerified: boolean; + panVerified: boolean; + incomeProofSubmitted: boolean; + bankStatementSubmitted: boolean; + propertyDetailsSubmitted: boolean; + taxReceiptSubmitted: boolean; + electricityBillSubmitted: boolean; + /** Aadhaar e-KYC biometric authentication (UIDAI) — captured on top of the OVD paper check. */ + aadhaarFingerprintCaptured: boolean; + monthlyIncome: number; + eligibleLoanAmount: number; + coBorrowerRequired: boolean; + coBorrowerName: string; + + // CIC / Credit Bureau check — mandatory before sanction under the CICRA, 2005 framework. + cicBureau: CicBureau; + cibilScore: number; + cicReportUploaded: boolean; + cicReportFileName: string; + cicUploadDate: string; + /** The FI request is "pushed" out to a field agency from here — Field Investigation itself + * only records what comes back. */ + fiRequestPushed: boolean; + fiRequestDate: string; + fiAgencyAssigned: string; + + // Field Investigation + addressVerification: VerificationCheck; + officeVerification: VerificationCheck; + propertyVerification: VerificationCheck; + propertyValuation: number; + agentReportUploaded: boolean; + agentReportFileName: string; + + // Personal Discussion / discrepancy handling + discrepancyType: DiscrepancyType; + personalDiscussionNotes: string; + personalDiscussionDate: string; + /** Customer's signed declaration taken after the personal discussion clears. */ + declarationTaken: boolean; + declarationDate: string; + + // Guarantor + guarantorRequired: boolean; + guarantorName: string; + + // "Reports Required" presented at Loan Committee, and the "Verification Checklist" run at Risk + // Approval — kept as their own fields since ID/Address/Income/FI-Report are already covered by + // the checks above and are re-derived from them, never duplicated. + backgroundCheckDone: boolean; + /** CSE — CERSAI Search Enquiry, the mandatory check for an existing charge on the same security. */ + cersaiSearchDone: boolean; + managerMeetingDone: boolean; + previousLoanDeclarationDone: boolean; + propertySearchReportDone: boolean; + valuationReportConfirmed: boolean; + + // Multi-level approvals (segregation of duties: Credit / Risk / Legal / Post-Check) + creditCommitteeApproval: ApprovalRecord; + riskApproval: ApprovalRecord; + legalApproval: ApprovalRecord; + postCheckApproval: ApprovalRecord; + + // Legal / documentation + feesAndAccountOpened: boolean; + mortgageCreated: boolean; + legalStampDone: boolean; + fileFilingDone: boolean; + agreementSigningSlot: string; + + // Signing / disbursement + customerSigningDate: string; + chequeReceived: boolean; + achSigned: boolean; + /** Post-Check document-signing sign-off, one flag per signatory the note calls for. */ + signedByAgent: boolean; + signedByManager: boolean; + signedByCommittee: boolean; + signedByRiskApproval: boolean; + signedByLegalApproval: boolean; + disbursementDate: string; + disbursementMode: string; + achConfirmationRef: string; + emiActivationConfirmed: boolean; + emiActivationDate: string; + + currentStage: OriginationStage; + overallStatus: OverallStatus; + stageHistory: StageHistoryEntry[]; +} + +// ------------------------------------------------------------------------- +// Date helpers (same convention as npa-data.ts) +// ------------------------------------------------------------------------- +function fmt(d: Date): string { + return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`; +} + +function addDays(d: Date, days: number): Date { + const r = new Date(d); + r.setDate(r.getDate() + days); + return r; +} + +function addMonths(d: Date, months: number): Date { + const r = new Date(d); + r.setMonth(r.getMonth() + months); + return r; +} + +/** Parses a "DD-MM-YYYY" string back into a local-time Date — deliberately NOT + * `new Date(isoString)`, which parses as UTC and can land on the wrong calendar day depending + * on the browser's local timezone offset. */ +function parseDMY(s: string): Date { + const [ + d, + m, + y + ] = s.split('-').map(Number); + return new Date(y, m - 1, d); +} + +export function todayDDMMYYYY(): string { + return fmt(new Date()); +} + +// ------------------------------------------------------------------------- +// Seeded RNG — keeps the generated pipeline stable across reloads +// ------------------------------------------------------------------------- +function mulberry32(seed: number): () => number { + return function random(): number { + seed |= 0; + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} +const rng = mulberry32(20240115); +const pick = (arr: T[]): T => arr[Math.floor(rng() * arr.length)]; +const between = (min: number, max: number): number => min + rng() * (max - min); +const round = (n: number): number => Math.round(n); + +// ------------------------------------------------------------------------- +// Masters +// ------------------------------------------------------------------------- +interface OriginationProduct { + code: string; + name: string; + secured: boolean; + minAmt: number; + maxAmt: number; + minRate: number; + maxRate: number; + minTenure: number; + maxTenure: number; + /** Rough FOIR-style income multiplier used to derive Eligible Loan Amount from Monthly Income. */ + incomeMultiplier: number; +} +export const ORIGINATION_PRODUCTS: OriginationProduct[] = [ + { + code: 'PL', + name: 'Personal Loan', + secured: false, + minAmt: 50000, + maxAmt: 500000, + minRate: 13, + maxRate: 16, + minTenure: 12, + maxTenure: 48, + incomeMultiplier: 20 + }, + { + code: 'HL', + name: 'Housing Loan', + secured: true, + minAmt: 1000000, + maxAmt: 4000000, + minRate: 8.5, + maxRate: 10.5, + minTenure: 120, + maxTenure: 240, + incomeMultiplier: 60 + }, + { + code: 'VL', + name: 'Vehicle Loan', + secured: true, + minAmt: 300000, + maxAmt: 1500000, + minRate: 9, + maxRate: 11.5, + minTenure: 36, + maxTenure: 84, + incomeMultiplier: 36 + }, + { + code: 'GL', + name: 'Gold Loan', + secured: true, + minAmt: 50000, + maxAmt: 1000000, + minRate: 9, + maxRate: 12, + minTenure: 6, + maxTenure: 12, + incomeMultiplier: 15 + }, + { + code: 'CC', + name: 'Cash Credit (MSME)', + secured: true, + minAmt: 500000, + maxAmt: 5000000, + minRate: 11, + maxRate: 13.5, + minTenure: 12, + maxTenure: 12, + incomeMultiplier: 40 + }, + { + code: 'KCC', + name: 'Kisan Credit Card', + secured: true, + minAmt: 100000, + maxAmt: 500000, + minRate: 7, + maxRate: 9, + minTenure: 12, + maxTenure: 12, + incomeMultiplier: 30 + }, + { + code: 'EL', + name: 'Education Loan', + secured: false, + minAmt: 200000, + maxAmt: 1500000, + minRate: 10, + maxRate: 12, + minTenure: 60, + maxTenure: 84, + incomeMultiplier: 45 + } +]; + +const FIRST_NAMES = [ + 'Ramesh', + 'Suresh', + 'Mahesh', + 'Ganesh', + 'Vijay', + 'Sanjay', + 'Ajay', + 'Prakash', + 'Sunita', + 'Anita', + 'Kavita', + 'Savita', + 'Rekha', + 'Meena', + 'Pooja', + 'Sangita', + 'Ravindra', + 'Mahendra', + 'Narendra', + 'Jitendra', + 'Bhagwan', + 'Vasant', + 'Yogesh', + 'Kiran', + 'Manisha', + 'Deepak', + 'Sandeep', + 'Pradeep', + 'Ashok', + 'Vishal', + 'Nitin', + 'Amol' +]; +const LAST_NAMES = [ + 'Patil', + 'More', + 'Jadhav', + 'Deshmukh', + 'Pawar', + 'Kulkarni', + 'Shinde', + 'Gaikwad', + 'Chavan', + 'Bhosale', + 'Kale', + 'Salunkhe', + 'Sawant', + 'Kadam', + 'Joshi', + 'Wagh', + 'Thorat', + 'Waghmare', + 'Mane', + 'Shaikh' +]; + +const BC_AGENTS = [ + 'Suvidha BC Point — Andheri', + 'Grameen Seva Kendra — Karad', + 'Jan Suraksha BC Point — Satara', + 'Sahyog BC Kendra — Solapur', + 'Bank Mitra Point — Kolhapur', + 'Digital Seva Kendra — Sangli' +]; + +const MANAGERS = [ + 'Anil Deshpande (Branch Manager)', + 'Sunil Kadam (Branch Manager)', + 'Vikas Pathak (Credit Manager)', + 'Rajendra More (Credit Manager)' +]; + +const RISK_OFFICERS = [ + 'Sachin Bhagat (Risk Officer)', + 'Prashant Jagtap (Risk Officer)', + 'Milind Kulkarni (Risk Officer)' +]; + +const LEGAL_OFFICERS = [ + 'Adv. Ravi Ghadge (Panel Advocate)', + 'Adv. Shubhada Rane (Panel Advocate)' +]; + +const FI_AGENCIES = [ + 'SecureCheck Verification Services', + 'TrustLine FI Agency', + 'VerifyIndia Field Services' +]; + +// ------------------------------------------------------------------------- +// Delegation of Powers — sanctioning authority by loan amount, exactly the kind +// of amount-based approval matrix every Indian bank/co-operative society runs. +// ------------------------------------------------------------------------- +export function sanctioningAuthority(amount: number): string { + if (amount <= 500000) { + return 'Branch Manager'; + } + if (amount <= 2500000) { + return 'Regional Credit Committee'; + } + return 'Head Office Credit Committee'; +} + +/** RBI/credit-bureau-standard CIBIL score bands. */ +export function cibilBand(score: number): 'Excellent' | 'Good' | 'Fair' | 'Poor' { + if (score >= 750) { + return 'Excellent'; + } + if (score >= 650) { + return 'Good'; + } + if (score >= 550) { + return 'Fair'; + } + return 'Poor'; +} + +export function progressPct(app: LoanApplication): number { + if (app.overallStatus === 'DISBURSED') { + return 100; + } + return round(((stageIndex(app.currentStage) + 1) / STAGE_ORDER.length) * 100); +} + +export function stageBadgeClass(app: LoanApplication): string { + if (app.overallStatus === 'REJECTED') { + return 'loan-badge loan-badge--rejected'; + } + if (app.overallStatus === 'ON_HOLD') { + return 'loan-badge loan-badge--hold'; + } + if (app.overallStatus === 'DISBURSED') { + return 'loan-badge loan-badge--disbursed'; + } + return 'loan-badge loan-badge--progress'; +} + +function blankVerification(): VerificationCheck { + return { status: 'PENDING', verifiedBy: '—', verifiedDate: '—', remarks: '—' }; +} + +function blankApproval(): ApprovalRecord { + return { decision: 'PENDING', approverName: '—', approverRole: '—', date: '—', remarks: '—' }; +} + +// ------------------------------------------------------------------------- +// Application generator — walks a fresh application through STAGE_ORDER up to +// a target stage (weighted, funnel-shaped: more applications sit at the early +// stages than the late ones), filling in realistic field values and a matching +// stageHistory entry for every stage actually completed. Applications beyond +// the target stage are left at their PENDING defaults — never partially filled +// — so the record always looks internally consistent with where it really is. +// ------------------------------------------------------------------------- +interface StageWeight { + outcome: 'IN_PROGRESS' | 'ON_HOLD' | 'REJECTED' | 'DISBURSED'; + /** Index into STAGE_ORDER the application is frozen at / progressed to. */ + atStage: number; + weight: number; +} +const OUTCOME_BUCKETS: StageWeight[] = [ + { outcome: 'IN_PROGRESS', atStage: 0, weight: 10 }, // APPLICATION + { outcome: 'IN_PROGRESS', atStage: 1, weight: 15 }, // KYC_DOCUMENTS + { outcome: 'IN_PROGRESS', atStage: 2, weight: 12 }, // CIC_CHECK + { outcome: 'IN_PROGRESS', atStage: 3, weight: 9 }, // FIELD_INVESTIGATION + { outcome: 'ON_HOLD', atStage: 4, weight: 5 }, // PERSONAL_DISCUSSION, flagged + { outcome: 'IN_PROGRESS', atStage: 5, weight: 8 }, // CREDIT_COMMITTEE + { outcome: 'REJECTED', atStage: 5, weight: 3 }, // rejected at Committee + { outcome: 'IN_PROGRESS', atStage: 6, weight: 6 }, // RISK_APPROVAL + { outcome: 'REJECTED', atStage: 6, weight: 2 }, // rejected at Risk + { outcome: 'IN_PROGRESS', atStage: 7, weight: 5 }, // LEGAL_APPROVAL + { outcome: 'IN_PROGRESS', atStage: 8, weight: 3 }, // CUSTOMER_SIGNING + { outcome: 'IN_PROGRESS', atStage: 9, weight: 2 }, // CHEQUE_ACH + { outcome: 'IN_PROGRESS', atStage: 10, weight: 2 }, // POST_CHECK + { outcome: 'DISBURSED', atStage: 11, weight: 18 } // fully disbursed +]; +const OUTCOME_TOTAL_WEIGHT = OUTCOME_BUCKETS.reduce((s, b) => s + b.weight, 0); + +function pickOutcomeBucket(): StageWeight { + let r = rng() * OUTCOME_TOTAL_WEIGHT; + for (const b of OUTCOME_BUCKETS) { + if (r < b.weight) { + return b; + } + r -= b.weight; + } + return OUTCOME_BUCKETS[0]; +} + +function generateApplications(count: number): LoanApplication[] { + const today = new Date(); + const apps: LoanApplication[] = []; + + for (let i = 0; i < count; i++) { + const bucket = pickOutcomeBucket(); + const branch = pick(BRANCHES); + const product = pick(ORIGINATION_PRODUCTS); + const channel: Channel = pick([ + 'BRANCH', + 'BRANCH', + 'BC', + 'SELF_APPLY' + ]); + const customerType: CustomerType = rng() < 0.55 ? 'EXISTING' : 'NEW'; + + const requestedAmount = round(between(product.minAmt, product.maxAmt) / 1000) * 1000; + const tenureMonths = round(between(product.minTenure, product.maxTenure)); + const interestRate = +between(product.minRate, product.maxRate).toFixed(2); + // Reverse-engineered so eligibility usually — but not always — comfortably covers what was + // asked for, the same way a real income/FOIR check sometimes comes up short. + const monthlyIncome = round((requestedAmount / product.incomeMultiplier) * between(0.85, 1.35)); + const eligibleLoanAmount = round(monthlyIncome * product.incomeMultiplier); + + // Bias CIBIL lower for applications that end up rejected at Committee/Risk — a poor score is + // the single most common real-world rejection reason. + const rejectedForCredit = bucket.outcome === 'REJECTED' && rng() < 0.7; + const cibilScore = rejectedForCredit ? round(between(400, 620)) : round(between(650, 850)); + + // Enough runway for every stage this application actually reaches, each a few days apart, + // plus a bit of slack — otherwise a far-progressed application could need an application + // date in the future to fit all its stage-history entries before today. + // Each stage after APPLICATION advances the cursor by up to 5 days (see the walk below), so + // the runway must comfortably exceed that worst case — otherwise a far-progressed + // application's stage-history could walk past today, which would look wrong on any date. + const stagesToWalk = bucket.atStage + 1; + const minRunwayDays = stagesToWalk * 5 + 10; + const applicationDate = fmt(addDays(today, -round(between(minRunwayDays, minRunwayDays + 90)))); + + const seq = String(20000 + i).padStart(6, '0'); + const applicantName = `${pick(FIRST_NAMES)} ${pick(LAST_NAMES)}`; + const manager = pick(MANAGERS); + const riskOfficer = pick(RISK_OFFICERS); + const legalOfficer = pick(LEGAL_OFFICERS); + const fiAgency = pick(FI_AGENCIES); + + const app: LoanApplication = { + applicationNo: `LN${branch.code.slice(2)}${product.code}${seq}`, + applicationDate, + applicantName, + branchCode: branch.code, + branchName: branch.name, + channel, + bcAgentName: channel === 'BC' ? pick(BC_AGENTS) : '—', + customerType, + productCode: product.code, + productName: product.name, + secured: product.secured, + requestedAmount, + tenureMonths, + interestRate, + isShareMember: customerType === 'EXISTING' && rng() < 0.85, + shareCertNo: '—', + shareMemberSince: '—', + aadhaarVerified: false, + panVerified: false, + incomeProofSubmitted: false, + bankStatementSubmitted: false, + propertyDetailsSubmitted: false, + taxReceiptSubmitted: false, + electricityBillSubmitted: false, + aadhaarFingerprintCaptured: false, + monthlyIncome, + eligibleLoanAmount, + coBorrowerRequired: eligibleLoanAmount < requestedAmount, + coBorrowerName: '—', + cicBureau: pick(CIC_BUREAUS), + cibilScore, + cicReportUploaded: false, + cicReportFileName: '—', + cicUploadDate: '—', + fiRequestPushed: false, + fiRequestDate: '—', + fiAgencyAssigned: '—', + addressVerification: blankVerification(), + officeVerification: blankVerification(), + propertyVerification: blankVerification(), + propertyValuation: product.secured ? round(requestedAmount * between(1.1, 1.4)) : 0, + agentReportUploaded: false, + agentReportFileName: '—', + discrepancyType: 'NONE', + personalDiscussionNotes: '—', + personalDiscussionDate: '—', + declarationTaken: false, + declarationDate: '—', + guarantorRequired: !product.secured && requestedAmount > 300000, + guarantorName: '—', + backgroundCheckDone: false, + cersaiSearchDone: false, + managerMeetingDone: false, + previousLoanDeclarationDone: false, + propertySearchReportDone: false, + valuationReportConfirmed: false, + creditCommitteeApproval: blankApproval(), + riskApproval: blankApproval(), + legalApproval: blankApproval(), + postCheckApproval: blankApproval(), + feesAndAccountOpened: false, + mortgageCreated: false, + legalStampDone: false, + fileFilingDone: false, + agreementSigningSlot: '—', + customerSigningDate: '—', + chequeReceived: false, + achSigned: false, + signedByAgent: false, + signedByManager: false, + signedByCommittee: false, + signedByRiskApproval: false, + signedByLegalApproval: false, + disbursementDate: '—', + disbursementMode: '—', + achConfirmationRef: '—', + emiActivationConfirmed: false, + emiActivationDate: '—', + currentStage: 'APPLICATION', + overallStatus: 'IN_PROGRESS', + stageHistory: [] + }; + + if (app.coBorrowerRequired) { + app.coBorrowerName = `${pick(FIRST_NAMES)} ${pick(LAST_NAMES)} (Co-Borrower)`; + } + if (app.guarantorRequired) { + app.guarantorName = `${pick(FIRST_NAMES)} ${pick(LAST_NAMES)} (Guarantor)`; + } + // Membership pre-dates the application itself (you join the society, then later apply for a + // loan) — so it's generated unconditionally here, never gated on how far this particular + // application has progressed. + if (app.isShareMember) { + app.shareCertNo = `SC-${branch.code}-${round(between(1000, 9999))}`; + app.shareMemberSince = fmt(addDays(parseDMY(applicationDate), -round(between(60, 1800)))); + } + + // Walk the application through every stage up to and including bucket.atStage, filling in + // the fields that stage's completion would actually set and logging a stageHistory entry — + // exactly what "Approve" on each stage does at runtime, replayed here at generation time. + let cursor = parseDMY(applicationDate); + + app.stageHistory.push({ + stage: 'APPLICATION', + date: applicationDate, + actionBy: channel === 'BC' ? app.bcAgentName : channel === 'SELF_APPLY' ? 'Customer (Digital)' : manager, + outcome: 'Received', + remarks: `Application received via ${CHANNEL_LABELS[channel]}.` + }); + + for (let s = 1; s <= bucket.atStage; s++) { + cursor = addDays(cursor, round(between(1, 5))); + const stage = STAGE_ORDER[s]; + const isRejectionStage = + bucket.outcome === 'REJECTED' && + s === bucket.atStage && + (stage === 'CREDIT_COMMITTEE' || stage === 'RISK_APPROVAL'); + const isHoldStage = bucket.outcome === 'ON_HOLD' && s === bucket.atStage && stage === 'PERSONAL_DISCUSSION'; + + switch (stage) { + case 'KYC_DOCUMENTS': + app.aadhaarVerified = true; + app.panVerified = true; + app.incomeProofSubmitted = true; + app.bankStatementSubmitted = true; + app.propertyDetailsSubmitted = product.secured; + app.taxReceiptSubmitted = product.secured; + app.electricityBillSubmitted = true; + app.aadhaarFingerprintCaptured = true; + app.stageHistory.push({ + stage, + date: fmt(cursor), + actionBy: manager, + outcome: 'Completed', + remarks: + 'Aadhaar/PAN (OVD) verified, Aadhaar fingerprint captured, income proof and bank statement collected.' + }); + break; + + case 'CIC_CHECK': + app.cicReportUploaded = true; + app.cicReportFileName = `${app.cicBureau.replace(/\s+/g, '')}_${app.applicationNo}.pdf`; + app.cicUploadDate = fmt(cursor); + app.fiRequestPushed = true; + app.fiRequestDate = fmt(cursor); + app.fiAgencyAssigned = fiAgency; + app.stageHistory.push({ + stage, + date: fmt(cursor), + actionBy: manager, + outcome: `${app.cicBureau} Score ${app.cibilScore}`, + remarks: `${app.cicBureau} credit report pulled and uploaded — score ${app.cibilScore} (${cibilBand(app.cibilScore)}). Field Inquiry pushed to ${fiAgency}.` + }); + break; + + case 'FIELD_INVESTIGATION': + app.addressVerification = { + status: 'VERIFIED', + verifiedBy: fiAgency, + verifiedDate: fmt(cursor), + remarks: 'Registered address confirmed on physical visit.' + }; + app.officeVerification = { + status: 'VERIFIED', + verifiedBy: fiAgency, + verifiedDate: fmt(cursor), + remarks: 'Employment/business confirmed — Agent Report filed.' + }; + if (product.secured) { + app.propertyVerification = { + status: 'VERIFIED', + verifiedBy: fiAgency, + verifiedDate: fmt(cursor), + remarks: `Property inspected and valued at ₹${app.propertyValuation.toLocaleString('en-IN')}.` + }; + } + app.agentReportUploaded = true; + app.agentReportFileName = `AgentReport_${app.applicationNo}.pdf`; + app.stageHistory.push({ + stage, + date: fmt(cursor), + actionBy: fiAgency, + outcome: 'Verified', + remarks: product.secured + ? 'Address, office and property field investigation completed — Agent Report uploaded with valuation.' + : 'Address and office field investigation completed — Agent Report uploaded.' + }); + break; + + case 'PERSONAL_DISCUSSION': + if (isHoldStage) { + app.discrepancyType = pick([ + 'DOCUMENT_DISCREPANCY', + 'NAME_MISMATCH', + 'OFFICE_VERIFICATION_FAILED', + 'PROPERTY_DISCREPANCY', + 'FI_REPORT_DISCREPANCY', + 'FAKE_DECLARATION' + ]); + app.personalDiscussionDate = fmt(cursor); + app.personalDiscussionNotes = `${DISCREPANCY_LABELS[app.discrepancyType]} flagged during personal discussion with ${manager} — held pending resolution.`; + app.stageHistory.push({ + stage, + date: fmt(cursor), + actionBy: manager, + outcome: 'On Hold', + remarks: app.personalDiscussionNotes + }); + } else { + app.personalDiscussionDate = fmt(cursor); + app.personalDiscussionNotes = 'No discrepancy found — cleared to proceed to Loan Committee.'; + app.declarationTaken = true; + app.declarationDate = fmt(cursor); + app.stageHistory.push({ + stage, + date: fmt(cursor), + actionBy: manager, + outcome: 'Cleared', + remarks: `${app.personalDiscussionNotes} Customer declaration taken.` + }); + } + break; + + case 'CREDIT_COMMITTEE': + app.backgroundCheckDone = true; + app.cersaiSearchDone = product.secured; + app.managerMeetingDone = true; + if (isRejectionStage) { + // Only blame the credit score when it's actually weak — a rejection alongside a + // perfectly good score would read as a contradiction ("Good credit score... below + // policy threshold"), so a decent-score decline cites a non-score reason instead. + const scoreIsWeak = app.cibilScore < 650; + app.creditCommitteeApproval = { + decision: 'REJECTED', + approverName: sanctioningAuthority(app.requestedAmount), + approverRole: 'Sanctioning Authority', + date: fmt(cursor), + remarks: scoreIsWeak + ? `Declined — ${cibilBand(app.cibilScore)} credit score (${app.cibilScore}) below policy threshold.` + : product.secured + ? `Declined — background check and CSE findings did not meet the committee's risk appetite, despite an adequate credit score (${app.cibilScore}).` + : `Declined — background check findings did not meet the committee's risk appetite, despite an adequate credit score (${app.cibilScore}).` + }; + } else { + app.creditCommitteeApproval = { + decision: 'APPROVED', + approverName: sanctioningAuthority(app.requestedAmount), + approverRole: 'Sanctioning Authority', + date: fmt(cursor), + remarks: product.secured + ? 'CIBIL report, background check, CSE, OVD, income proof, FI report, manager meeting and share-member status reviewed — approved.' + : 'CIBIL report, background check, OVD, income proof, FI report, manager meeting and share-member status reviewed — approved.' + }; + } + app.stageHistory.push({ + stage, + date: fmt(cursor), + actionBy: app.creditCommitteeApproval.approverName, + outcome: APPROVAL_LABELS[app.creditCommitteeApproval.decision], + remarks: app.creditCommitteeApproval.remarks + }); + break; + + case 'RISK_APPROVAL': + app.previousLoanDeclarationDone = true; + app.propertySearchReportDone = product.secured; + app.valuationReportConfirmed = product.secured; + if (isRejectionStage) { + // A guarantor requirement only applies to unsecured loans above the threshold amount + // (see guarantorRequired above); a secured loan has no guarantor to speak of, so its + // decline reason is about the security cover instead — never both in the same remark. + app.riskApproval = { + decision: 'REJECTED', + approverName: riskOfficer, + approverRole: 'Risk Approval', + date: fmt(cursor), + remarks: app.guarantorRequired + ? 'Guarantor requirement not met — no eligible guarantor furnished.' + : 'Security cover inadequate relative to the outstanding exposure.' + }; + } else { + app.riskApproval = { + decision: 'APPROVED', + approverName: riskOfficer, + approverRole: 'Risk Approval', + date: fmt(cursor), + remarks: product.secured + ? 'ID/address and income proof verified, previous-loan declaration, property search and valuation report, FI report and personal background check all clear.' + : 'ID/address and income proof verified, previous-loan declaration, FI report and personal background check all clear.' + }; + } + app.stageHistory.push({ + stage, + date: fmt(cursor), + actionBy: riskOfficer, + outcome: APPROVAL_LABELS[app.riskApproval.decision], + remarks: app.riskApproval.remarks + }); + break; + + case 'LEGAL_APPROVAL': + app.feesAndAccountOpened = true; + app.mortgageCreated = product.secured; + app.legalStampDone = true; + app.fileFilingDone = true; + app.agreementSigningSlot = fmt(addDays(cursor, 2)); + app.legalApproval = { + decision: 'APPROVED', + approverName: legalOfficer, + approverRole: 'Legal Approval', + date: fmt(cursor), + remarks: product.secured + ? 'Mortgage created, legal stamp and file filing completed.' + : 'Legal stamp and file filing completed; loan agreement cleared for signing.' + }; + app.stageHistory.push({ + stage, + date: fmt(cursor), + actionBy: legalOfficer, + outcome: 'Approved', + remarks: `${app.legalApproval.remarks} Signing slot allotted for ${app.agreementSigningSlot}.` + }); + break; + + case 'CUSTOMER_SIGNING': + app.customerSigningDate = fmt(cursor); + app.stageHistory.push({ + stage, + date: fmt(cursor), + actionBy: app.applicantName, + outcome: 'Signed', + remarks: 'Loan agreement and sanction letter acknowledgement signed by customer.' + }); + break; + + case 'CHEQUE_ACH': + app.chequeReceived = true; + app.achSigned = true; + app.stageHistory.push({ + stage, + date: fmt(cursor), + actionBy: manager, + outcome: 'Confirmed', + remarks: 'Disbursement cheque prepared and ACH mandate signed.' + }); + break; + + case 'POST_CHECK': + app.signedByAgent = true; + app.signedByManager = true; + app.signedByCommittee = true; + app.signedByRiskApproval = true; + app.signedByLegalApproval = true; + app.postCheckApproval = { + decision: 'APPROVED', + approverName: manager, + approverRole: 'Post-Disbursement Check', + date: fmt(cursor), + remarks: + 'Document signing by Agent, Manager, Committee, Risk Approval and Legal Approval all verified on file.' + }; + app.stageHistory.push({ + stage, + date: fmt(cursor), + actionBy: manager, + outcome: 'Cleared', + remarks: app.postCheckApproval.remarks + }); + break; + + case 'DISBURSED': + app.disbursementDate = fmt(cursor); + app.disbursementMode = pick([ + 'Account Credit', + 'NEFT', + 'RTGS' + ]); + app.achConfirmationRef = `ACH${app.applicationNo.slice(-6)}${round(between(100, 999))}`; + app.emiActivationConfirmed = true; + app.emiActivationDate = fmt(cursor); + app.stageHistory.push({ + stage, + date: fmt(cursor), + actionBy: manager, + outcome: 'Disbursed', + remarks: `Disbursed via ${app.disbursementMode} — ACH confirmation ${app.achConfirmationRef}. EMI activation confirmed.` + }); + break; + + default: + break; + } + } + + app.currentStage = STAGE_ORDER[bucket.atStage]; + app.overallStatus = bucket.outcome; + apps.push(app); + } + + return apps; +} + +// Sized like a real cooperative bank's active sanctioning pipeline — enough to need filters +// and pagination, not so many it stops reading as "an active queue". +export const LOAN_APPLICATIONS: LoanApplication[] = generateApplications(140); + +// ------------------------------------------------------------------------- +// Runtime actions — every button in the Application Detail screen calls one of these to mutate +// the application in place and append a stageHistory entry, so the audit trail is always exactly +// what actually happened, in order, never a separately-maintained log that could drift. +// ------------------------------------------------------------------------- +function recordStageProgress(app: LoanApplication, actionBy: string, outcome: string, remarks: string): void { + app.stageHistory.push({ stage: app.currentStage, date: todayDDMMYYYY(), actionBy, outcome, remarks }); +} + +/** Generic "this stage is done, move to the next one" action — used by every stage that doesn't + * carry its own ApprovalRecord (Application, KYC, CIC, Field Investigation, Customer Signing, + * Cheque/ACH). */ +export function proceedToNextStage(app: LoanApplication, actionBy: string, remarks: string): void { + recordStageProgress(app, actionBy, 'Completed', remarks); + const next = nextStage(app.currentStage); + if (next) { + app.currentStage = next; + } +} + +export function flagDiscrepancy(app: LoanApplication, type: DiscrepancyType, actionBy: string, notes: string): void { + app.discrepancyType = type; + app.personalDiscussionDate = todayDDMMYYYY(); + app.personalDiscussionNotes = notes; + app.overallStatus = 'ON_HOLD'; + recordStageProgress(app, actionBy, 'On Hold', notes); +} + +export function resolveDiscrepancyAndProceed(app: LoanApplication, actionBy: string, remarks: string): void { + app.discrepancyType = 'NONE'; + app.overallStatus = 'IN_PROGRESS'; + app.personalDiscussionNotes = remarks; + app.declarationTaken = true; + app.declarationDate = todayDDMMYYYY(); + proceedToNextStage(app, actionBy, `${remarks} Customer declaration taken.`); +} + +/** Clears Personal Discussion with no discrepancy found — the note's "after discussion, take + * declaration" step happens right here, as part of the same action. */ +export function clearPersonalDiscussionAndProceed(app: LoanApplication, actionBy: string, remarks: string): void { + app.personalDiscussionDate = todayDDMMYYYY(); + app.personalDiscussionNotes = remarks; + app.declarationTaken = true; + app.declarationDate = todayDDMMYYYY(); + proceedToNextStage(app, actionBy, `${remarks} Customer declaration taken.`); +} + +export type ApprovalField = 'creditCommitteeApproval' | 'riskApproval' | 'legalApproval' | 'postCheckApproval'; + +/** Records an Approved/Rejected/Exception-Approved decision against whichever approval stage is + * currently active, and either advances the pipeline (Approved) or freezes it (Rejected) — the + * same segregation-of-duties gate every one of Credit Committee / Risk / Legal / Post-Check runs. */ +export function decideApproval( + app: LoanApplication, + field: ApprovalField, + decision: ApprovalDecision, + approverName: string, + approverRole: string, + remarks: string +): void { + app[field] = { decision, approverName, approverRole, date: todayDDMMYYYY(), remarks }; + if (decision === 'REJECTED') { + app.overallStatus = 'REJECTED'; + recordStageProgress(app, approverName, 'Rejected', remarks); + return; + } + recordStageProgress(app, approverName, APPROVAL_LABELS[decision], remarks); + const next = nextStage(app.currentStage); + if (next) { + app.currentStage = next; + } +} + +/** + * Pulls the applicant's score from the selected bureau — the actual "CIBIL check" a real CBS + * fires as an API call to the bureau. This is the step that turns `cibilScore` from 0 (never + * checked) into a real number; only after this can the physical/PDF report be uploaded as + * documentary evidence, and Field Inquiry be pushed to an agency. + */ +export function checkCreditScore(app: LoanApplication, actionBy: string): void { + app.cibilScore = Math.round(300 + Math.random() * 600); + recordStageProgress( + app, + actionBy, + `${app.cicBureau} Score ${app.cibilScore}`, + `${app.cicBureau} credit score checked — ${app.cibilScore} (${cibilBand(app.cibilScore)}).` + ); +} + +export function uploadCicReport(app: LoanApplication, fileName: string, actionBy: string): void { + app.cicReportUploaded = true; + app.cicReportFileName = fileName; + app.cicUploadDate = todayDDMMYYYY(); + recordStageProgress( + app, + actionBy, + 'Uploaded', + `${app.cicBureau} credit report "${fileName}" uploaded — score ${app.cibilScore} (${cibilBand(app.cibilScore)}).` + ); +} + +/** Dispatches the Field Inquiry request to a field agency — the "push" step that happens during + * the CIC/Credit Check stage, before Field Investigation itself records what comes back. */ +export function pushFieldInquiry(app: LoanApplication, agencyName: string, actionBy: string): void { + app.fiRequestPushed = true; + app.fiRequestDate = todayDDMMYYYY(); + app.fiAgencyAssigned = agencyName; + recordStageProgress(app, actionBy, 'FI Pushed', `Field Inquiry request pushed to ${agencyName}.`); +} + +export function uploadAgentReport(app: LoanApplication, fileName: string, actionBy: string): void { + app.agentReportUploaded = true; + app.agentReportFileName = fileName; + recordStageProgress(app, actionBy, 'Uploaded', `Agent Report "${fileName}" uploaded.`); +} + +export function recordVerification( + app: LoanApplication, + which: 'addressVerification' | 'officeVerification' | 'propertyVerification', + status: VerificationStatus, + verifiedBy: string, + remarks: string +): void { + app[which] = { status, verifiedBy, verifiedDate: todayDDMMYYYY(), remarks }; +} + +/** Cooperative-bank membership can be allotted independently of the loan's own progress — a + * branch can sign someone up as a member at any point in the review, not just at Committee. */ +/** Eligible Loan Amount = Monthly Income × the product's income multiplier (a rough FOIR-style + * rule of thumb) — the same formula the seeded book uses at generation time, exposed here so a + * freshly-created application (which starts with monthlyIncome/eligibleLoanAmount at 0, since + * income isn't known until KYC collects it) can run the exact same calculation once it is. */ +export function calcEligibleAmount(monthlyIncome: number, productCode: string): number { + const product = ORIGINATION_PRODUCTS.find((p) => p.code === productCode); + return product ? Math.round(monthlyIncome * product.incomeMultiplier) : 0; +} + +/** Records the Monthly Income collected during KYC and derives the Eligible Loan Amount from + * it — also re-checks whether a co-borrower is now required (income might not cover what was + * requested), same rule the seeded book applies at generation time. */ +export function setMonthlyIncome(app: LoanApplication, monthlyIncome: number, actionBy: string): void { + app.monthlyIncome = monthlyIncome; + app.eligibleLoanAmount = calcEligibleAmount(monthlyIncome, app.productCode); + app.coBorrowerRequired = app.eligibleLoanAmount < app.requestedAmount; + if (app.coBorrowerRequired && app.coBorrowerName === '—') { + app.coBorrowerName = 'To be added'; + } + recordStageProgress( + app, + actionBy, + 'Income Assessed', + `Monthly income ₹${monthlyIncome.toLocaleString('en-IN')} recorded — eligible loan amount ₹${app.eligibleLoanAmount.toLocaleString('en-IN')}.` + ); +} + +export function allotShareMembership(app: LoanApplication, actionBy: string): void { + app.isShareMember = true; + app.shareCertNo = `SC-${app.branchCode}-${Math.floor(1000 + Math.random() * 8999)}`; + app.shareMemberSince = todayDDMMYYYY(); + recordStageProgress( + app, + actionBy, + 'Share Membership Allotted', + `Membership shares allotted — Certificate ${app.shareCertNo}.` + ); +} + +export function markCustomerSigned(app: LoanApplication, actionBy: string): void { + app.customerSigningDate = todayDDMMYYYY(); + proceedToNextStage(app, actionBy, 'Loan agreement and sanction letter acknowledgement signed by customer.'); +} + +export function confirmChequeAch(app: LoanApplication, actionBy: string): void { + app.chequeReceived = true; + app.achSigned = true; + proceedToNextStage(app, actionBy, 'Disbursement cheque prepared and ACH mandate signed.'); +} + +export function disburseApplication(app: LoanApplication, actionBy: string, mode: string): void { + app.disbursementDate = todayDDMMYYYY(); + app.disbursementMode = mode; + app.achConfirmationRef = `ACH${app.applicationNo.slice(-6)}${Math.floor(100 + Math.random() * 899)}`; + app.emiActivationConfirmed = true; + app.emiActivationDate = todayDDMMYYYY(); + app.overallStatus = 'DISBURSED'; + recordStageProgress( + app, + actionBy, + 'Disbursed', + `Disbursed via ${mode} — ACH confirmation ${app.achConfirmationRef}. EMI activation confirmed.` + ); +} + +// ------------------------------------------------------------------------- +// EMI Repayment Schedule — the standard reducing-balance amortisation table a real CBS shows +// once a term loan is disbursed. Cash Credit / KCC are revolving limits renewed annually, not +// repaid on a fixed instalment schedule, so they're excluded rather than shown a misleading one. +// ------------------------------------------------------------------------- +export function isRevolvingProduct(productCode: string): boolean { + return productCode === 'CC' || productCode === 'KCC'; +} + +export interface EmiScheduleRow { + installmentNo: number; + dueDate: string; + openingBalance: number; + emiAmount: number; + principalComponent: number; + interestComponent: number; + closingBalance: number; +} + +/** Standard reducing-balance EMI formula: EMI = P × r × (1+r)^n / ((1+r)^n − 1), r = monthly rate. */ +export function calculateEmi(principal: number, annualRatePct: number, tenureMonths: number): number { + const r = annualRatePct / 12 / 100; + if (r === 0) { + return Math.round(principal / tenureMonths); + } + const factor = Math.pow(1 + r, tenureMonths); + return Math.round((principal * r * factor) / (factor - 1)); +} + +/** Builds the full month-by-month amortisation schedule. The final instalment absorbs whatever + * rounding drift has accumulated from rounding the EMI/interest every month, so the schedule + * always closes to exactly ₹0 outstanding — never a stray few rupees left over, exactly how a + * real CBS's closing instalment is computed. */ +export function buildEmiSchedule( + principal: number, + annualRatePct: number, + tenureMonths: number, + startDate: string +): EmiScheduleRow[] { + const emi = calculateEmi(principal, annualRatePct, tenureMonths); + const r = annualRatePct / 12 / 100; + const start = parseDMY(startDate); + const rows: EmiScheduleRow[] = []; + let balance = principal; + + for (let i = 1; i <= tenureMonths; i++) { + const interestComponent = Math.round(balance * r); + const isLast = i === tenureMonths; + const principalComponent = isLast ? balance : Math.min(balance, emi - interestComponent); + const emiAmount = isLast ? principalComponent + interestComponent : emi; + const closingBalance = Math.max(0, balance - principalComponent); + rows.push({ + installmentNo: i, + dueDate: fmt(addMonths(start, i)), + openingBalance: balance, + emiAmount, + principalComponent, + interestComponent, + closingBalance + }); + balance = closingBalance; + } + return rows; +} + +// ------------------------------------------------------------------------- +// New Application intake — the Pipeline screen's "New Application" form builds one of these and +// appends it to LOAN_APPLICATIONS, exactly at the APPLICATION stage with everything else pending. +// ------------------------------------------------------------------------- +export interface NewApplicationInput { + channel: Channel; + bcAgentName: string; + customerType: CustomerType; + applicantName: string; + branchCode: string; + branchName: string; + productCode: string; + productName: string; + secured: boolean; + requestedAmount: number; + tenureMonths: number; + interestRate: number; +} + +export function createApplication(input: NewApplicationInput, seq: number): LoanApplication { + const actionBy = + input.channel === 'BC' ? input.bcAgentName : input.channel === 'SELF_APPLY' ? 'Customer (Digital)' : 'Branch Staff'; + return { + applicationNo: `LN${input.branchCode.slice(2)}${input.productCode}${String(seq).padStart(6, '0')}`, + applicationDate: todayDDMMYYYY(), + applicantName: input.applicantName, + branchCode: input.branchCode, + branchName: input.branchName, + channel: input.channel, + bcAgentName: input.channel === 'BC' ? input.bcAgentName : '—', + customerType: input.customerType, + productCode: input.productCode, + productName: input.productName, + secured: input.secured, + requestedAmount: input.requestedAmount, + tenureMonths: input.tenureMonths, + interestRate: input.interestRate, + isShareMember: false, + shareCertNo: '—', + shareMemberSince: '—', + aadhaarVerified: false, + panVerified: false, + incomeProofSubmitted: false, + bankStatementSubmitted: false, + propertyDetailsSubmitted: false, + taxReceiptSubmitted: false, + electricityBillSubmitted: false, + aadhaarFingerprintCaptured: false, + monthlyIncome: 0, + eligibleLoanAmount: 0, + coBorrowerRequired: false, + coBorrowerName: '—', + cicBureau: 'CIBIL', + cibilScore: 0, + cicReportUploaded: false, + cicReportFileName: '—', + cicUploadDate: '—', + fiRequestPushed: false, + fiRequestDate: '—', + fiAgencyAssigned: '—', + addressVerification: blankVerification(), + officeVerification: blankVerification(), + propertyVerification: blankVerification(), + propertyValuation: 0, + agentReportUploaded: false, + agentReportFileName: '—', + discrepancyType: 'NONE', + personalDiscussionNotes: '—', + personalDiscussionDate: '—', + declarationTaken: false, + declarationDate: '—', + guarantorRequired: false, + guarantorName: '—', + backgroundCheckDone: false, + cersaiSearchDone: false, + managerMeetingDone: false, + previousLoanDeclarationDone: false, + propertySearchReportDone: false, + valuationReportConfirmed: false, + creditCommitteeApproval: blankApproval(), + riskApproval: blankApproval(), + legalApproval: blankApproval(), + postCheckApproval: blankApproval(), + feesAndAccountOpened: false, + mortgageCreated: false, + legalStampDone: false, + fileFilingDone: false, + agreementSigningSlot: '—', + customerSigningDate: '—', + chequeReceived: false, + achSigned: false, + signedByAgent: false, + signedByManager: false, + signedByCommittee: false, + signedByRiskApproval: false, + signedByLegalApproval: false, + disbursementDate: '—', + disbursementMode: '—', + achConfirmationRef: '—', + emiActivationConfirmed: false, + emiActivationDate: '—', + currentStage: 'APPLICATION', + overallStatus: 'IN_PROGRESS', + stageHistory: [ + { + stage: 'APPLICATION', + date: todayDDMMYYYY(), + actionBy, + outcome: 'Received', + remarks: `Application received via ${CHANNEL_LABELS[input.channel]}.` + } + ] + }; +} + +// ------------------------------------------------------------------------- +// Disbursement Targets — the branch/product/channel business targets a real bank sets each +// cycle (here, annual) and tracks achievement against, same discipline as the Investment +// module's "Portfolio Allocation — Target vs Actual" section. +// ------------------------------------------------------------------------- + +/** Annual branch-wise disbursement target (₹) — a business-plan number set once per branch, + * independent of how many applications happen to be sitting in the pipeline right now. */ +export const BRANCH_DISBURSEMENT_TARGET: Record = { + BR01: 40000000, + BR02: 28000000, + BR03: 18000000, + BR04: 16000000, + BR05: 15000000, + BR06: 14000000, + BR07: 13000000, + BR08: 12000000 +}; + +export function branchTargetAmount(branchCode: string): number { + return BRANCH_DISBURSEMENT_TARGET[branchCode] || 0; +} + +/** Target product mix (% of total disbursement) — weighted towards secured Housing/Vehicle + * lending plus the priority-sector KCC/Cash Credit book, adding up to 100%. */ +export const PRODUCT_TARGET_PCT: Record = { + PL: 12, + HL: 28, + VL: 15, + GL: 10, + CC: 15, + KCC: 12, + EL: 8 +}; + +/** Target channel mix (% of disbursement) — the bank's push towards BC/Digital origination and + * away from pure branch walk-ins. */ +export const CHANNEL_TARGET_PCT: Record = { + BRANCH: 55, + BC: 30, + SELF_APPLY: 15 +}; + +export type TargetStatus = 'AHEAD' | 'ON_TRACK' | 'BEHIND'; + +export const TARGET_STATUS_LABELS: Record = { + AHEAD: 'Ahead of Target', + ON_TRACK: 'On Track', + BEHIND: 'Behind Target' +}; + +/** Compares an actual % against its target %, tolerant of small day-to-day noise — + * `toleranceMultiplier` widens the band for branch-level achievement (target vs 100%) vs the + * tighter band appropriate for a product/channel mix percentage. */ +export function targetStatus(actualPct: number, targetPct: number, tolerancePp: number): TargetStatus { + const diff = actualPct - targetPct; + if (Math.abs(diff) <= tolerancePp) { + return 'ON_TRACK'; + } + return diff > 0 ? 'AHEAD' : 'BEHIND'; +} diff --git a/src/app/loan-origination/loan-origination-routing.module.ts b/src/app/loan-origination/loan-origination-routing.module.ts new file mode 100644 index 000000000..efa7a9b5b --- /dev/null +++ b/src/app/loan-origination/loan-origination-routing.module.ts @@ -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 {} diff --git a/src/app/loan-origination/loan-origination-shared.scss b/src/app/loan-origination/loan-origination-shared.scss new file mode 100644 index 000000000..bdc9d9817 --- /dev/null +++ b/src/app/loan-origination/loan-origination-shared.scss @@ -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; + } +} diff --git a/src/app/loan-origination/loan-origination.module.ts b/src/app/loan-origination/loan-origination.module.ts new file mode 100644 index 000000000..56545acbf --- /dev/null +++ b/src/app/loan-origination/loan-origination.module.ts @@ -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 {} diff --git a/src/app/loan-origination/loan-pipeline/loan-pipeline.component.html b/src/app/loan-origination/loan-pipeline/loan-pipeline.component.html new file mode 100644 index 000000000..3bae3f4d8 --- /dev/null +++ b/src/app/loan-origination/loan-pipeline/loan-pipeline.component.html @@ -0,0 +1,558 @@ + + +
+
+
+
CREDIT · LOAN ORIGINATION · {{ scopeName | uppercase }}
+

Loan Origination Pipeline

+

+ Every sanctioning stage from application intake to disbursement — KYC/OVD, CIC credit check, Field + Investigation, Personal Discussion, Credit Committee, Risk & Legal Approval — as on {{ asOnDate }}. +

+
+
+ @if (isHeadOffice) { + + } @else { + + } + +
+
+ +
+
+ Total Applications + {{ totalApplications }} + in the active pipeline +
+
+ In Progress + {{ inProgressCount }} + moving through the workflow +
+
+ On Hold + {{ onHoldCount }} + discrepancy under resolution +
+
+ Rejected + {{ rejectedCount }} + declined at Committee/Risk +
+
+ Disbursed + {{ disbursedApplications.length }} + ₹{{ disbursedAmount | number: '1.0-0' }} total +
+
+ +
+ + + + + + +
+ + + @if (activeView === 'pipeline') { +
+ +
+ + + + +
+
+ +
+ +
+ + + + + + + + + + + + + + + + @for (a of paged(filteredApplications); track a.applicationNo) { + + + + + + + + + + + + } @empty { + + + + } + +
Application NoApplicantBranchChannelProductRequested AmountCurrent StageProgressStatus
+ {{ a.applicationNo }} + {{ a.applicantName }}{{ a.branchName }}{{ channelLabels[a.channel] }}{{ a.productName }}₹{{ a.requestedAmount | number: '1.0-0' }}{{ stageLabel(a.currentStage) }}{{ progress(a) }}% + {{ statusLabel(a.overallStatus) }} +
No applications match these filters.
+
+ @if (filteredApplications.length > pageSize) { +
+ Showing {{ rangeStart(filteredApplications) }}–{{ rangeEnd(filteredApplications) }} of + {{ filteredApplications.length }} +
+ + Page {{ currentPage }} of {{ pageCount(filteredApplications) }} + +
+
+ } +
+ } + + + @if (activeView === 'new') { +
+ +
+ + @if (newApp.channel === 'BC') { + + } + + + + + + +
+
+ +
+
+ } + + + @if (activeView === 'channel') { +
+ + + + + + + + + + + + + @for (r of channelReport; track r.channel) { + + + + + + + + } + + + + + + + + + + +
ChannelApplicationsRequested AmountDisbursed CountDisbursed Amount
{{ r.label }}{{ r.count }}₹{{ r.requestedAmount | number: '1.0-0' }}{{ r.disbursedCount }}₹{{ r.disbursedAmount | number: '1.0-0' }}
Total{{ totalApplications }} + ₹{{ + channelReport[0].requestedAmount + channelReport[1].requestedAmount + channelReport[2].requestedAmount + | number: '1.0-0' + }} + {{ disbursedApplications.length }}₹{{ disbursedAmount | number: '1.0-0' }}
+
+ } + + + @if (activeView === 'bc') { +
+ + + + + + + + + + + + + @for (r of bcReport; track r.bcAgentName) { + + + + + + + + } @empty { + + + + } + +
BC Agent / PointApplicationsRequested AmountDisbursed CountDisbursed Amount
{{ r.bcAgentName }}{{ r.count }}₹{{ r.requestedAmount | number: '1.0-0' }}{{ r.disbursedCount }}₹{{ r.disbursedAmount | number: '1.0-0' }}
No BC-sourced applications in this branch.
+
+ } + + + @if (activeView === 'share') { +
+ +

+ 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. +

+
+ + + + + + + + + + + + @for (a of paged(nonMemberApplications); track a.applicationNo) { + + + + + + + + } @empty { + + + + } + +
Application NoApplicantBranchCurrent StageStatus
+ {{ a.applicationNo }} + {{ a.applicantName }}{{ a.branchName }}{{ stageLabel(a.currentStage) }} + {{ statusLabel(a.overallStatus) }} +
Every active applicant in this branch already holds membership.
+
+ @if (nonMemberApplications.length > pageSize) { +
+ Showing {{ rangeStart(nonMemberApplications) }}–{{ rangeEnd(nonMemberApplications) }} of + {{ nonMemberApplications.length }} +
+ + Page {{ currentPage }} of {{ pageCount(nonMemberApplications) }} + +
+
+ } +
+ } + + + @if (activeView === 'target') { +
+ + + + + + + + + + + + + @for (row of branchTargetRows; track row.branchCode) { + + + + + + + + } + +
BranchTargetDisbursed (Actual)Achievement %Status
{{ row.branchName }}₹{{ row.target | number: '1.0-0' }}₹{{ row.actual | number: '1.0-0' }}{{ row.achievementPct | number: '1.1-1' }}% + + {{ statusLabelOf(row.status) }} + +
+
+ +
+ + + + + + + + + + + + + @for (row of productTargetRows; track row.code) { + + + + + + + + } + +
ProductTarget %Actual %VarianceStatus
{{ row.label }}{{ row.targetPct | number: '1.0-0' }}%{{ row.actualPct | number: '1.1-1' }}% + {{ row.variancePct > 0 ? '+' : '' }}{{ row.variancePct | number: '1.1-1' }} pp + + + {{ statusLabelOf(row.status) }} + +
+
+ +
+ + + + + + + + + + + + + @for (row of channelTargetRows; track row.code) { + + + + + + + + } + +
ChannelTarget %Actual %VarianceStatus
{{ row.label }}{{ row.targetPct | number: '1.0-0' }}%{{ row.actualPct | number: '1.1-1' }}% + {{ row.variancePct > 0 ? '+' : '' }}{{ row.variancePct | number: '1.1-1' }} pp + + + {{ statusLabelOf(row.status) }} + +
+
+ } +
+ +
{{ toastMsg }}
diff --git a/src/app/loan-origination/loan-pipeline/loan-pipeline.component.ts b/src/app/loan-origination/loan-pipeline/loan-pipeline.component.ts new file mode 100644 index 000000000..382d07888 --- /dev/null +++ b/src/app/loan-origination/loan-pipeline/loan-pipeline.component.ts @@ -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 | 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(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 { + 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); + } +}