/** * 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); } }