feat(loan-origination): persist the sanctioning pipeline to the backend
Replaces the in-memory mock (LOAN_APPLICATIONS array + seeded RNG, reset on every refresh) with real Fineract-backed persistence: a new LoanOriginationService for CRUD plus one API call per stage transition, a loan-fineract-bridge that creates a real loan account (create + approve + disburse) at disbursement for Existing Customers, and file uploads through Fineract's generic document store keyed by a stable checklist slot so "On file"/View controls survive a reload. Also fixes several gaps found while verifying full-cycle persistence: - CIC charge-account selection never saved (missing persist() call) - Co-borrower relationship/monthly income lived in local-only component state instead of on the application, so they reset on refresh - Field Investigation "View Full Details" showed local-session photos only; stored photos are now downloaded and shown as real thumbnails - "Assign Field Officer" was free text; now a dropdown of real CBS loan officers pulled from /staff - site photos and cheque/signature images now open in the same zoomable, pannable lightgallery viewer used elsewhere in the app Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,13 +11,17 @@ import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { Subscription, forkJoin } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { Observable, Subscription, forkJoin } from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
import { jsPDF } from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||
import { Dates } from '../../core/utils/dates';
|
||||
import { ClientsService } from '../../clients/clients.service';
|
||||
import { LoansService } from '../../loans/loans.service';
|
||||
import { ProductsService } from '../../products/products.service';
|
||||
import { SettingsService } from '../../settings/settings.service';
|
||||
import { SharesService } from '../../shares/shares.service';
|
||||
import { KycUploadHelperService } from '../../clients/shared/kyc-upload-helper.service';
|
||||
import { DocumentPreviewDialogComponent } from '../../clients/shared/document-preview-dialog/document-preview-dialog.component';
|
||||
@@ -36,6 +40,8 @@ import {
|
||||
} from './cic-report-analysis-dialog/cic-report-analysis.util';
|
||||
import { parseCicPdfReport } from './cic-report-analysis-dialog/cic-pdf-parser.util';
|
||||
import { FieldVisitDetailsDialogComponent } from './field-visit-details-dialog/field-visit-details-dialog.component';
|
||||
import { createRealLoanAccount } from '../loan-fineract-bridge';
|
||||
import { LoanOriginationService } from '../loan-origination.service';
|
||||
import {
|
||||
ApprovalField,
|
||||
BRANCHES,
|
||||
@@ -44,7 +50,6 @@ import {
|
||||
DISCREPANCY_LABELS,
|
||||
EmiScheduleRow,
|
||||
LoanApplication,
|
||||
LOAN_APPLICATIONS,
|
||||
ORIGINATION_PRODUCTS,
|
||||
OriginationStage,
|
||||
STAGE_LABELS,
|
||||
@@ -59,28 +64,20 @@ import {
|
||||
cibilBand,
|
||||
CIC_BUREAUS,
|
||||
CIC_CHECK_CHARGE,
|
||||
clearPersonalDiscussionAndProceed,
|
||||
confirmChequeAch,
|
||||
decideApproval,
|
||||
disburseApplication,
|
||||
FiCheckKey,
|
||||
FI_CHECK_LABELS,
|
||||
flagDiscrepancy,
|
||||
MANAGERS,
|
||||
markCustomerSigned,
|
||||
moveToStage,
|
||||
ProductDocumentEntry,
|
||||
progressPct,
|
||||
PROPERTY_TYPES,
|
||||
pushFieldInquiry,
|
||||
resolveDiscrepancyAndProceed,
|
||||
sanctioningAuthority,
|
||||
scheduleMeeting,
|
||||
setMonthlyIncome,
|
||||
stageBadgeClass,
|
||||
stageIndex,
|
||||
proceedToNextStage,
|
||||
recordVerification,
|
||||
todayDDMMYYYY,
|
||||
uploadAgentReport,
|
||||
uploadCicReport
|
||||
@@ -163,6 +160,16 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
actionRemarks = '';
|
||||
discrepancySelection: DiscrepancyType = 'NONE';
|
||||
disbursementMode = 'Account Credit';
|
||||
/** True while the real Fineract loan account (Existing Customer applications only) is being
|
||||
* created/approved/disbursed via loan-fineract-bridge.ts. */
|
||||
creatingRealLoan = false;
|
||||
|
||||
loadingApplication = false;
|
||||
/** True while a save is in flight; drives the "Saving…"/"Saved" indicator so an officer can tell
|
||||
* their work has actually reached the database rather than assuming it. */
|
||||
saving = false;
|
||||
saveFailed = false;
|
||||
private inlineEditTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
/** KYC stage's Income & Affordability inputs -- always editable (not just before the first
|
||||
* "Assess"), pre-filled from the application and re-committed on every click, so the officer
|
||||
* can revisit and adjust income, requested amount or tenure as many times as needed. */
|
||||
@@ -186,7 +193,12 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
private readonly auth = inject(AuthenticationService);
|
||||
private readonly clientsService = inject(ClientsService);
|
||||
private readonly sharesService = inject(SharesService);
|
||||
private readonly loansService = inject(LoansService);
|
||||
private readonly productsService = inject(ProductsService);
|
||||
private readonly dateUtils = inject(Dates);
|
||||
private readonly settingsService = inject(SettingsService);
|
||||
private readonly kycUploadHelper = inject(KycUploadHelperService);
|
||||
private readonly originationService = inject(LoanOriginationService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private sub?: Subscription;
|
||||
|
||||
@@ -249,27 +261,20 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
|
||||
/** Once `coBorrowerRequired` fires (either the eligible-amount shortfall in setMonthlyIncome()
|
||||
* or the 40%-EMI rule in setIncomeNow()), the officer opts into actually adding one via this
|
||||
* checkbox -- relationship, their own monthly income and last-3-months' salary slips, all
|
||||
* local-only like the rest of this mock pipeline. Their income only counts toward affordability
|
||||
* once all of that is in, see combinedEmiCapacity()/affordabilityOk() below. */
|
||||
* or the 40%-EMI rule in setIncomeNow()), the officer opts into actually adding one via
|
||||
* `a.coBorrowerWanted`. Their name, relationship and monthly income live directly on the
|
||||
* application (so they survive a refresh); this holds only the in-flight local File objects for
|
||||
* the last-3-months' salary slips, which are otherwise tracked server-side via `hasStoredDoc`.
|
||||
* Their income only counts toward affordability once all of that is in, see
|
||||
* combinedEmiCapacity()/affordabilityOk() below. */
|
||||
coBorrowerCapture: {
|
||||
wanted: boolean;
|
||||
relationship: string;
|
||||
monthlyIncome: number | null;
|
||||
salarySlips: NewKycSingleDoc[];
|
||||
} = this.emptyCoBorrowerCapture();
|
||||
|
||||
private emptyCoBorrowerCapture(): {
|
||||
wanted: boolean;
|
||||
relationship: string;
|
||||
monthlyIncome: number | null;
|
||||
salarySlips: NewKycSingleDoc[];
|
||||
} {
|
||||
return {
|
||||
wanted: false,
|
||||
relationship: '',
|
||||
monthlyIncome: null,
|
||||
salarySlips: [
|
||||
{ file: null, previewUrl: null },
|
||||
{ file: null, previewUrl: null },
|
||||
@@ -295,6 +300,10 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
shopVerification: ''
|
||||
};
|
||||
|
||||
/** Real CBS staff to pick a Field Officer from -- loaded once, not per-application, since it
|
||||
* doesn't depend on which application is open. */
|
||||
fiStaffOptions: { id: number; displayName: string }[] = [];
|
||||
|
||||
/** Ditto for the rejection remark textarea. */
|
||||
fiRejectRemarkInput: Record<FiCheckKey, string> = {
|
||||
addressVerification: '',
|
||||
@@ -406,13 +415,42 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
this.fiTickTimer = setInterval(() => {
|
||||
this.nowTick = Date.now();
|
||||
}, 60000);
|
||||
this.originationService.listStaff().subscribe({
|
||||
next: (staff) => (this.fiStaffOptions = staff ?? []),
|
||||
error: () => (this.fiStaffOptions = [])
|
||||
});
|
||||
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;
|
||||
if (!applicationNo) {
|
||||
this.application = undefined;
|
||||
this.resetForApplication();
|
||||
return;
|
||||
}
|
||||
this.loadingApplication = true;
|
||||
this.originationService.get(applicationNo).subscribe({
|
||||
next: (found: LoanApplication) => {
|
||||
// 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 = !this.isHeadOffice && found.branchCode !== this.ownBranchCode ? undefined : found;
|
||||
this.resetForApplication();
|
||||
this.loadingApplication = false;
|
||||
},
|
||||
error: () => {
|
||||
this.application = undefined;
|
||||
this.resetForApplication();
|
||||
this.loadingApplication = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Clears every piece of per-application transient state (locally-captured files, in-progress
|
||||
* inputs, search results) and re-primes the per-document capture maps for whichever application
|
||||
* is now on screen. Split out of the route subscription because the application itself now
|
||||
* arrives asynchronously from the database. */
|
||||
private resetForApplication(): void {
|
||||
{
|
||||
this.actionRemarks = '';
|
||||
this.discrepancySelection = 'NONE';
|
||||
this.monthlyIncomeInput = this.application?.monthlyIncome || null;
|
||||
@@ -468,7 +506,10 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
this.loadRealClientAccounts();
|
||||
this.loadRealKyc();
|
||||
this.loadRealAddresses();
|
||||
});
|
||||
this.uploadingDocs = 0;
|
||||
this.docUploadFailed = false;
|
||||
this.refreshDocuments(this.application);
|
||||
}
|
||||
}
|
||||
|
||||
/** Only applications raised against a real Existing Customer (clientId set) get a real
|
||||
@@ -689,6 +730,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
target[side] = cropped;
|
||||
target[previewKey] = cropped.type.startsWith('image/') ? URL.createObjectURL(cropped) : null;
|
||||
this.uploadCaptured(cropped, `KYC:${doc}:${side}`, `${doc === 'aadhaar' ? 'Aadhaar' : 'PAN'} — ${side}`);
|
||||
}
|
||||
|
||||
sendNewAadhaarOtp(): void {
|
||||
@@ -762,6 +804,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
this.setSingleDoc(this.newKycCapture.incomeProof.salaried, file);
|
||||
this.uploadCaptured(file, 'KYC:incomeProof:salaried', 'Form 16 / 3 Month Salary Slip');
|
||||
this.syncIncomeProofSubmitted(a);
|
||||
}
|
||||
|
||||
@@ -771,6 +814,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
this.setSingleDoc(this.newKycCapture.incomeProof.itr[yearIndex], file);
|
||||
this.uploadCaptured(file, `KYC:incomeProof:itr:${yearIndex}`, `ITR — Year ${yearIndex + 1}`);
|
||||
this.syncIncomeProofSubmitted(a);
|
||||
}
|
||||
|
||||
@@ -804,6 +848,8 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
this.setSingleDoc(this.newKycCapture.bankStatement, file);
|
||||
a.bankStatementSubmitted = true;
|
||||
this.uploadCaptured(file, 'KYC:bankStatement', 'Bank Statement');
|
||||
this.persist();
|
||||
if (this.isCsvFile(file)) {
|
||||
await this.analyzeBankStatement(file);
|
||||
}
|
||||
@@ -823,6 +869,37 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
void this.analyzeBankStatement(file);
|
||||
}
|
||||
|
||||
/** True once a CSV bank statement is available to re-analyze, whether it's the file just picked
|
||||
* in this browser tab or one already saved from an earlier session. */
|
||||
canReopenBankStatementAnalysis(): boolean {
|
||||
if (this.isCsvFile(this.newKycCapture.bankStatement.file)) {
|
||||
return true;
|
||||
}
|
||||
const stored = this.storedDocsFor('KYC:bankStatement')[0];
|
||||
return !!stored && (stored.fileName ?? '').toLowerCase().endsWith('.csv');
|
||||
}
|
||||
|
||||
/** Same re-analysis as `reopenBankStatementAnalysis`, but also covers a statement that only
|
||||
* exists in the document store -- pulled down from the server first since there's no local
|
||||
* File object for it after a reload. */
|
||||
reopenBankStatementAnalysisFor(a: LoanApplication): void {
|
||||
if (this.newKycCapture.bankStatement.file) {
|
||||
this.reopenBankStatementAnalysis(this.newKycCapture.bankStatement.file);
|
||||
return;
|
||||
}
|
||||
const stored = this.storedDocsFor('KYC:bankStatement')[0];
|
||||
if (!stored || !a.id) {
|
||||
return;
|
||||
}
|
||||
this.originationService.downloadDocument(a.id, stored.id).subscribe({
|
||||
next: (blob: Blob) => {
|
||||
const file = new File([blob], stored.fileName ?? 'Bank Statement.csv', { type: 'text/csv' });
|
||||
void this.analyzeBankStatement(file);
|
||||
},
|
||||
error: () => this.toast('Could not load the stored bank statement.')
|
||||
});
|
||||
}
|
||||
|
||||
/** A CSV bank statement can actually be read, unlike a PDF/photo -- so dropping one in here
|
||||
* opens straight into the transaction analysis instead of just filing it away unread. */
|
||||
private async analyzeBankStatement(file: File): Promise<void> {
|
||||
@@ -861,6 +938,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
this.setSingleDoc(this.newKycCapture.electricityBill, file);
|
||||
this.uploadCaptured(file, 'KYC:electricityBill', 'Electricity Bill');
|
||||
this.syncElectricityBillSubmitted(a);
|
||||
}
|
||||
|
||||
@@ -879,6 +957,8 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
this.setSingleDoc(this.productDocCapture[doc.key], file);
|
||||
doc.submitted = true;
|
||||
this.uploadCaptured(file, `PRODUCT_DOC:${doc.key}`, doc.label);
|
||||
this.persist();
|
||||
}
|
||||
|
||||
/** Not every product's required-document list anticipates everything a specific file might
|
||||
@@ -893,6 +973,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
a.productDocuments.push({ key, label, custom: true, submitted: false });
|
||||
this.productDocCapture[key] = { file: null, previewUrl: null };
|
||||
this.newCustomDocLabel = '';
|
||||
this.persist();
|
||||
}
|
||||
|
||||
/** Only officer-added documents can be removed -- a product's own required list stays fixed,
|
||||
@@ -910,6 +991,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
URL.revokeObjectURL(capture.previewUrl);
|
||||
}
|
||||
delete this.productDocCapture[doc.key];
|
||||
this.persist();
|
||||
}
|
||||
|
||||
// -------------------- Legal documents (Legal Approval) --------------------
|
||||
@@ -928,12 +1010,26 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
this.legalDocCapture[doc.key] = [];
|
||||
}
|
||||
for (const file of files) {
|
||||
this.legalDocCapture[doc.key].push({
|
||||
const captured: NewKycSingleDoc = {
|
||||
file,
|
||||
previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : null
|
||||
};
|
||||
this.legalDocCapture[doc.key].push(captured);
|
||||
// Once the document store has it, drop the local capture so the same file doesn't render
|
||||
// twice -- once as this in-session thumb, once again from `storedDocsFor` after the reload.
|
||||
this.uploadCaptured(file, `LEGAL_DOC:${doc.key}`, doc.label, () => {
|
||||
const files = this.legalDocCapture[doc.key];
|
||||
const index = files?.indexOf(captured) ?? -1;
|
||||
if (index >= 0) {
|
||||
if (captured.previewUrl) {
|
||||
URL.revokeObjectURL(captured.previewUrl);
|
||||
}
|
||||
files.splice(index, 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
doc.submitted = true;
|
||||
this.persist();
|
||||
}
|
||||
|
||||
removeLegalDocFile(doc: ProductDocumentEntry, index: number): void {
|
||||
@@ -976,6 +1072,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
a.legalDocuments.push({ key, label, custom: true, submitted: false });
|
||||
this.legalDocCapture[key] = [];
|
||||
this.newLegalDocLabel = '';
|
||||
this.persist();
|
||||
}
|
||||
|
||||
removeCustomLegalDoc(a: LoanApplication, doc: ProductDocumentEntry): void {
|
||||
@@ -992,6 +1089,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
});
|
||||
delete this.legalDocCapture[doc.key];
|
||||
this.persist();
|
||||
}
|
||||
|
||||
onCoOwnerToggle(a: LoanApplication): void {
|
||||
@@ -1005,6 +1103,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
this.setSingleDoc(this.coBorrowerCapture.salarySlips[index], file);
|
||||
this.uploadCaptured(file, `COBORROWER:salarySlip:${index}`, `Co-Borrower Salary Slip — Month ${index + 1}`);
|
||||
}
|
||||
|
||||
/** Single source of truth for "is there actually a usable co-borrower on file" -- name,
|
||||
@@ -1014,12 +1113,14 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
coBorrowerDetailsComplete(a: LoanApplication): boolean {
|
||||
const hasName = !!a.coBorrowerName && a.coBorrowerName !== '—' && a.coBorrowerName !== 'To be added';
|
||||
return (
|
||||
this.coBorrowerCapture.wanted &&
|
||||
a.coBorrowerWanted &&
|
||||
hasName &&
|
||||
!!this.coBorrowerCapture.relationship &&
|
||||
!!this.coBorrowerCapture.monthlyIncome &&
|
||||
this.coBorrowerCapture.monthlyIncome > 0 &&
|
||||
this.coBorrowerCapture.salarySlips.every((doc) => !!doc.file)
|
||||
!!a.coBorrowerRelationship &&
|
||||
!!a.coBorrowerMonthlyIncome &&
|
||||
a.coBorrowerMonthlyIncome > 0 &&
|
||||
this.coBorrowerCapture.salarySlips.every(
|
||||
(doc, index) => !!doc.file || this.hasStoredDoc(`COBORROWER:salarySlip:${index}`)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1027,15 +1128,15 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
return a.monthlyIncome * 0.4;
|
||||
}
|
||||
|
||||
coBorrowerEmiCapacity(): number {
|
||||
return (this.coBorrowerCapture.monthlyIncome || 0) * 0.4;
|
||||
coBorrowerEmiCapacity(a: LoanApplication): number {
|
||||
return (a.coBorrowerMonthlyIncome || 0) * 0.4;
|
||||
}
|
||||
|
||||
/** The 40% affordability rule applied to the household rather than the applicant alone: 40% of
|
||||
* the applicant's own income, plus 40% of the co-borrower's (once their details are actually
|
||||
* complete -- income and salary slips, not just a number typed in), added together. */
|
||||
combinedEmiCapacity(a: LoanApplication): number {
|
||||
const coBorrowerCapacity = this.coBorrowerDetailsComplete(a) ? this.coBorrowerEmiCapacity() : 0;
|
||||
const coBorrowerCapacity = this.coBorrowerDetailsComplete(a) ? this.coBorrowerEmiCapacity(a) : 0;
|
||||
return this.applicantEmiCapacity(a) + coBorrowerCapacity;
|
||||
}
|
||||
|
||||
@@ -1059,7 +1160,20 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
maxWidth: '95vw'
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe(() => this.syncCoOwnerDocSubmitted(a));
|
||||
.subscribe(() => {
|
||||
this.syncCoOwnerDocSubmitted(a);
|
||||
// Files are captured inside the dialog (state is passed by reference), so they're uploaded
|
||||
// here once it closes rather than at pick time.
|
||||
const state = this.coOwnerCapture[docType];
|
||||
const label = docType === 'aadhaar' ? 'Aadhaar' : 'PAN';
|
||||
if (state.front) {
|
||||
this.uploadCaptured(state.front, `COOWNER:${docType}:front`, `Co-Owner ${label} — front`);
|
||||
}
|
||||
if (state.back) {
|
||||
this.uploadCaptured(state.back, `COOWNER:${docType}:back`, `Co-Owner ${label} — back`);
|
||||
}
|
||||
this.persist();
|
||||
});
|
||||
}
|
||||
|
||||
/** No co-owner ticked means there's nothing to collect, so the requirement is satisfied by
|
||||
@@ -1103,7 +1217,181 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------- Uploaded documents --------------------
|
||||
/** Document rows already stored against this application, so a captured file is still there after
|
||||
* a reload instead of only living in this browser session. */
|
||||
uploadedDocs: any[] = [];
|
||||
/** Count of uploads currently in flight, so the UI can say so instead of looking idle. */
|
||||
uploadingDocs = 0;
|
||||
docUploadFailed = false;
|
||||
|
||||
/**
|
||||
* Sends a captured file to the document store under a stable key.
|
||||
*
|
||||
* The key (stored as the document's `name`) is what lets the file be matched back to its checklist
|
||||
* slot on the next page load -- without it a reloaded screen would know a file exists but not which
|
||||
* checkbox it belongs to. `label` is the human-readable text the officer sees.
|
||||
*/
|
||||
private uploadCaptured(file: File, key: string, label: string, onUploaded?: () => void): void {
|
||||
const a = this.application;
|
||||
if (!a?.id) {
|
||||
// Application not persisted yet -- nothing to attach to. The local preview still shows.
|
||||
return;
|
||||
}
|
||||
this.uploadingDocs++;
|
||||
this.docUploadFailed = false;
|
||||
this.originationService.uploadDocument(a.id, file, key, label).subscribe({
|
||||
next: () => {
|
||||
this.uploadingDocs--;
|
||||
this.refreshDocuments(a);
|
||||
onUploaded?.();
|
||||
},
|
||||
error: () => {
|
||||
this.uploadingDocs--;
|
||||
this.docUploadFailed = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private refreshDocuments(a: LoanApplication | undefined): void {
|
||||
if (!a?.id) {
|
||||
this.uploadedDocs = [];
|
||||
return;
|
||||
}
|
||||
this.originationService.listDocuments(a.id).subscribe({
|
||||
next: (docs: any[]) => {
|
||||
this.uploadedDocs = docs ?? [];
|
||||
},
|
||||
error: () => {
|
||||
this.uploadedDocs = [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Every stored file filed under one checklist slot (a slot can hold several, e.g. FI site photos). */
|
||||
storedDocsFor(key: string): any[] {
|
||||
return this.uploadedDocs.filter((doc) => doc.name === key);
|
||||
}
|
||||
|
||||
hasStoredDoc(key: string): boolean {
|
||||
return this.storedDocsFor(key).length > 0;
|
||||
}
|
||||
|
||||
/** One "View" button for a KYC slot that works whether the file is still local (just picked in
|
||||
* this browser tab) or only exists in the document store (picked in an earlier session) --
|
||||
* so the slot doesn't need two separate view controls for the same document. */
|
||||
viewKycSlot(a: LoanApplication, localFile: File | null, key: string): void {
|
||||
if (localFile) {
|
||||
this.viewNewSingleDoc(localFile);
|
||||
return;
|
||||
}
|
||||
const stored = this.storedDocsFor(key)[0];
|
||||
if (stored) {
|
||||
this.viewStoredDoc(a, stored);
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens a stored file in the same preview dialog used for client KYC documents. */
|
||||
viewStoredDoc(a: LoanApplication, doc: any): void {
|
||||
if (!a.id) {
|
||||
return;
|
||||
}
|
||||
this.originationService.downloadDocument(a.id, doc.id).subscribe({
|
||||
next: (blob: Blob) => {
|
||||
this.dialog.open(DocumentPreviewDialogComponent, {
|
||||
data: { blob, title: doc.description || doc.name, fileName: doc.fileName }
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.toast('Could not open the stored document.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
removeStoredDoc(a: LoanApplication, doc: any): void {
|
||||
if (!a.id) {
|
||||
return;
|
||||
}
|
||||
this.originationService.deleteDocument(a.id, doc.id).subscribe({
|
||||
next: () => this.refreshDocuments(a),
|
||||
error: () => this.toast('Could not remove the stored document.')
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------- Stage transitions (server-validated) --------------------
|
||||
/**
|
||||
* Runs a stage action on the server and adopts whatever it returns.
|
||||
*
|
||||
* The transition itself is applied server-side, so the local mutators are deliberately NOT called
|
||||
* for these: the screen shows the state the server actually accepted rather than a client guess
|
||||
* that a rejected action would leave visibly wrong. A rejection surfaces the server's own reason
|
||||
* (e.g. "The visit has no GPS coordinates recorded") instead of a generic failure.
|
||||
*/
|
||||
private runStageAction(action: Observable<LoanApplication>): void {
|
||||
this.saving = true;
|
||||
this.saveFailed = false;
|
||||
action.subscribe({
|
||||
next: (updated: LoanApplication) => {
|
||||
this.application = updated;
|
||||
this.actionRemarks = '';
|
||||
this.saving = false;
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.saving = false;
|
||||
this.saveFailed = true;
|
||||
this.toast(
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
'The server rejected this action — check the stage requirements.'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------- Persistence --------------------
|
||||
/** Writes the whole application back to the database. Called after every stage action, and
|
||||
* (debounced) after inline field edits -- the local mutators have already updated the object, so
|
||||
* this is deliberately the entire object rather than a per-action patch: there is no field list
|
||||
* here that could fall out of step with the model and silently drop an officer's entry. */
|
||||
persist(): void {
|
||||
if (!this.application) {
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.saveFailed = false;
|
||||
this.originationService.save(this.application).subscribe({
|
||||
next: () => {
|
||||
this.saving = false;
|
||||
},
|
||||
error: () => {
|
||||
// Left on screen rather than reverting: the officer's typing is still in the object and
|
||||
// still visible, and clearing it on a transient network failure would lose real work.
|
||||
this.saving = false;
|
||||
this.saveFailed = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Bound once on the stage-card container, so it catches every `[(ngModel)]` field on this screen
|
||||
* -- including ones added later -- instead of needing a handler wired per input. Both `input` and
|
||||
* `change` bubble, and Angular's own ngModel listener sits on the input itself, so by the time
|
||||
* this runs the model already holds the new value. Debounced so a typed field saves once. */
|
||||
onInlineEdit(): void {
|
||||
clearTimeout(this.inlineEditTimer);
|
||||
this.inlineEditTimer = setTimeout(() => this.persist(), 800);
|
||||
}
|
||||
|
||||
/** Flushes a pending debounce so navigating away mid-edit can't drop the last keystrokes. */
|
||||
private flushPendingInlineEdit(): void {
|
||||
if (this.inlineEditTimer) {
|
||||
clearTimeout(this.inlineEditTimer);
|
||||
this.inlineEditTimer = undefined;
|
||||
this.persist();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.flushPendingInlineEdit();
|
||||
this.sub?.unsubscribe();
|
||||
this.revokeNewKycPreviews(this.newKycCapture);
|
||||
this.revokeProductDocPreviews();
|
||||
@@ -1218,6 +1506,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
checkCreditScore(a, this.currentUserName);
|
||||
this.persist();
|
||||
}
|
||||
|
||||
/** Uploading the customer's signed copy of the consent declaration is what actually unlocks
|
||||
@@ -1236,6 +1525,8 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
this.setSingleDoc(this.cicConsentCapture, cropped);
|
||||
a.cicConsentSigned = true;
|
||||
this.uploadCaptured(cropped, 'CIC:consent', 'Signed Consent Declaration');
|
||||
this.persist();
|
||||
}
|
||||
|
||||
// -------------------- CIC check charge --------------------
|
||||
@@ -1251,6 +1542,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
a.cicChargeAccountLabel = `${account.accountNo} — ${account.kind} (${account.productName})`;
|
||||
a.cicChargeCollected = true;
|
||||
this.toast(`Rs. ${this.cicCheckCharge} CIC check charge marked for debit from ${account.accountNo}.`);
|
||||
this.persist();
|
||||
}
|
||||
|
||||
/** New Customer -- no account exists yet, so this mirrors a real payment-gateway collection
|
||||
@@ -1258,6 +1550,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
generateCicPaymentLink(a: LoanApplication): void {
|
||||
a.cicChargeMode = 'PAYMENT_LINK';
|
||||
a.cicPaymentLinkUrl = `https://pay.cbsnexora.local/cic-charge/${a.applicationNo}?amount=${this.cicCheckCharge}`;
|
||||
this.persist();
|
||||
}
|
||||
|
||||
copyCicPaymentLink(a: LoanApplication): void {
|
||||
@@ -1303,6 +1596,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
a.cicPaymentReference = this.cicPaymentRefInput.trim();
|
||||
a.cicChargeCollected = true;
|
||||
this.cicPaymentRefInput = '';
|
||||
this.persist();
|
||||
this.toast('Payment reference recorded — CIC check charge marked as collected.');
|
||||
}
|
||||
|
||||
@@ -1480,8 +1774,37 @@ I confirm that:
|
||||
}
|
||||
|
||||
// -------------------- Generic stage-advance actions --------------------
|
||||
/** Advance for the stages that have no checklist of their own to enforce (currently only
|
||||
* Application → KYC). Every stage that DOES gate on something goes through its own
|
||||
* server-validated endpoint instead -- see completeKycStage/completeCicStage/completeFi and the
|
||||
* approval methods. */
|
||||
proceed(a: LoanApplication, remarks: string): void {
|
||||
proceedToNextStage(a, this.currentUserName, remarks);
|
||||
this.persist();
|
||||
}
|
||||
|
||||
/** KYC → CIC. The server re-checks Aadhaar/PAN, income proof, bank statement, address proof,
|
||||
* income assessment and every product-specific document before it will let the stage move. */
|
||||
completeKycStage(a: LoanApplication): void {
|
||||
this.runStageAction(
|
||||
this.originationService.completeKyc(
|
||||
a.applicationNo,
|
||||
this.currentUserName,
|
||||
'KYC/OVD documents verified and collected.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** CIC → Field Investigation. The server re-checks the signed bureau consent, the collected
|
||||
* charge and the uploaded report. */
|
||||
completeCicStage(a: LoanApplication): void {
|
||||
this.runStageAction(
|
||||
this.originationService.completeCicCheck(
|
||||
a.applicationNo,
|
||||
this.currentUserName,
|
||||
`${a.cicBureau} report reviewed — score ${a.cibilScore} (${this.cibilBandOf(a.cibilScore)}).`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Any stage the application has ever reached is clickable -- whether it's currently behind or
|
||||
@@ -1502,6 +1825,7 @@ I confirm that:
|
||||
return;
|
||||
}
|
||||
moveToStage(a, stage, this.currentUserName);
|
||||
this.persist();
|
||||
}
|
||||
|
||||
// -------------------- KYC & Documents --------------------
|
||||
@@ -1606,6 +1930,7 @@ I confirm that:
|
||||
a.coBorrowerName = 'To be added';
|
||||
}
|
||||
}
|
||||
this.persist();
|
||||
}
|
||||
|
||||
allKycDone(a: LoanApplication): boolean {
|
||||
@@ -1661,6 +1986,7 @@ I confirm that:
|
||||
URL.revokeObjectURL(doc.previewUrl);
|
||||
}
|
||||
});
|
||||
this.persist();
|
||||
}
|
||||
|
||||
async onFiPhotoFile(a: LoanApplication, key: FiCheckKey, event: Event): Promise<void> {
|
||||
@@ -1672,8 +1998,10 @@ I confirm that:
|
||||
file,
|
||||
previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : null
|
||||
});
|
||||
this.uploadCaptured(file, `FI:${key}`, `${this.fiCheckLabels[key]} — site photo`);
|
||||
}
|
||||
a[key].photoCount = this.fiPhotoCapture[key].length;
|
||||
this.persist();
|
||||
}
|
||||
|
||||
removeFiPhoto(a: LoanApplication, key: FiCheckKey, index: number): void {
|
||||
@@ -1682,6 +2010,7 @@ I confirm that:
|
||||
URL.revokeObjectURL(removed.previewUrl);
|
||||
}
|
||||
a[key].photoCount = this.fiPhotoCapture[key].length;
|
||||
this.persist();
|
||||
}
|
||||
|
||||
/** A visit can only be marked Verified once the officer has actually captured something --
|
||||
@@ -1696,7 +2025,16 @@ I confirm that:
|
||||
if (!this.fiCanVerify(a, key)) {
|
||||
return;
|
||||
}
|
||||
recordVerification(a, key, 'VERIFIED', a[key].officerAssigned, 'Address visited and confirmed correct.');
|
||||
// The GPS fix and photo count are re-checked server-side against the stored record, so a visit
|
||||
// can't be marked verified on the strength of the client alone.
|
||||
this.runStageAction(
|
||||
this.originationService.verifyFieldCheck(
|
||||
a.applicationNo,
|
||||
key,
|
||||
a[key].officerAssigned,
|
||||
'Address visited and confirmed correct.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
rejectFieldVisit(a: LoanApplication, key: FiCheckKey): void {
|
||||
@@ -1704,8 +2042,8 @@ I confirm that:
|
||||
if (!remark) {
|
||||
return;
|
||||
}
|
||||
recordVerification(a, key, 'FAILED', a[key].officerAssigned, remark);
|
||||
this.fiRejectRemarkInput[key] = '';
|
||||
this.runStageAction(this.originationService.rejectFieldCheck(a.applicationNo, key, a[key].officerAssigned, remark));
|
||||
}
|
||||
|
||||
/** Hours elapsed since the officer was assigned -- the raw number the 48-hour SLA is checked
|
||||
@@ -1733,17 +2071,55 @@ I confirm that:
|
||||
}
|
||||
|
||||
completeFi(a: LoanApplication): void {
|
||||
this.proceed(a, 'Field Investigation completed — Agent Report filed.');
|
||||
this.runStageAction(
|
||||
this.originationService.completeFieldInvestigation(
|
||||
a.applicationNo,
|
||||
this.currentUserName,
|
||||
'Field Investigation completed — Agent Report filed.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Once a visit is Verified or Failed, the inline card only shows a short summary -- this opens
|
||||
* the full record (officer, GPS, every site photo, remarks) in a popup instead of cramming it
|
||||
* all into the card and breaking the grid layout. */
|
||||
openFiVisitDetails(a: LoanApplication, key: FiCheckKey): void {
|
||||
this.dialog.open(FieldVisitDetailsDialogComponent, {
|
||||
data: { label: this.fiCheckLabels[key], check: a[key], photos: this.fiPhotoCapture[key] },
|
||||
width: '520px',
|
||||
maxWidth: '95vw'
|
||||
const stored = this.storedDocsFor(`FI:${key}`);
|
||||
const applicationId = a.id;
|
||||
// Thumbnails need the actual bytes, not just the document record -- so every stored photo is
|
||||
// downloaded up front (in parallel) rather than lazily on click, the same as a local capture's
|
||||
// preview is available immediately. A failed download still shows the file name instead of
|
||||
// silently dropping the photo from the grid.
|
||||
const withPreviews =
|
||||
!applicationId || stored.length === 0
|
||||
? of([])
|
||||
: forkJoin(
|
||||
stored.map((doc) =>
|
||||
this.originationService.downloadDocument(applicationId, doc.id).pipe(
|
||||
map((blob) => ({ ...doc, previewUrl: URL.createObjectURL(blob) })),
|
||||
catchError(() => of({ ...doc, previewUrl: null }))
|
||||
)
|
||||
)
|
||||
);
|
||||
withPreviews.subscribe((storedPhotos) => {
|
||||
const ref = this.dialog.open(FieldVisitDetailsDialogComponent, {
|
||||
data: {
|
||||
label: this.fiCheckLabels[key],
|
||||
check: a[key],
|
||||
photos: this.fiPhotoCapture[key],
|
||||
storedPhotos,
|
||||
onViewStored: (doc: { id: number; fileName?: string }) => this.viewStoredDoc(a, doc)
|
||||
},
|
||||
width: '520px',
|
||||
maxWidth: '95vw'
|
||||
});
|
||||
ref.afterClosed().subscribe(() => {
|
||||
storedPhotos.forEach((p: { previewUrl: string | null }) => {
|
||||
if (p.previewUrl) {
|
||||
URL.revokeObjectURL(p.previewUrl);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1806,6 +2182,7 @@ I confirm that:
|
||||
a.meetingWith.trim(),
|
||||
this.currentUserName
|
||||
);
|
||||
this.persist();
|
||||
}
|
||||
|
||||
// -------------------- Application Summary Report (briefing doc for the meeting) --------------------
|
||||
@@ -2060,35 +2437,38 @@ I confirm that:
|
||||
}
|
||||
|
||||
clearPersonalDiscussion(a: LoanApplication): void {
|
||||
clearPersonalDiscussionAndProceed(
|
||||
a,
|
||||
this.currentUserName,
|
||||
this.actionRemarks.trim() || 'No discrepancy found — cleared to proceed to Loan Committee.'
|
||||
this.runStageAction(
|
||||
this.originationService.clearPersonalDiscussion(
|
||||
a.applicationNo,
|
||||
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 = '';
|
||||
const type = this.discrepancySelection;
|
||||
this.discrepancySelection = 'NONE';
|
||||
this.runStageAction(this.originationService.flagDiscrepancy(a.applicationNo, this.currentUserName, type, notes));
|
||||
}
|
||||
|
||||
resolveDiscrepancy(a: LoanApplication): void {
|
||||
resolveDiscrepancyAndProceed(
|
||||
a,
|
||||
this.currentUserName,
|
||||
this.actionRemarks.trim() || 'Discrepancy resolved on further review — cleared to proceed.'
|
||||
this.runStageAction(
|
||||
this.originationService.resolveDiscrepancy(
|
||||
a.applicationNo,
|
||||
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);
|
||||
this.persist();
|
||||
}
|
||||
|
||||
// -------------------- Approvals (Credit Committee / Risk / Legal / Post-Check) --------------------
|
||||
@@ -2103,45 +2483,49 @@ I confirm that:
|
||||
if (!this.committeeCanApprove(a)) {
|
||||
return;
|
||||
}
|
||||
const remarks = `${this.actionRemarks.trim() || 'Reviewed and approved.'} (Resolution No. ${a.committeeResolutionNo.trim()} dated ${a.committeeResolutionDate.trim()})`;
|
||||
decideApproval(a, 'creditCommitteeApproval', 'APPROVED', this.currentUserName, 'Sanctioning Authority', remarks);
|
||||
this.actionRemarks = '';
|
||||
this.runStageAction(
|
||||
this.originationService.decideApproval(a.applicationNo, 'creditCommitteeApproval', {
|
||||
decision: 'APPROVED',
|
||||
approverName: this.currentUserName,
|
||||
approverRole: 'Sanctioning Authority',
|
||||
remarks: this.actionRemarks.trim() || 'Reviewed and approved.',
|
||||
resolutionNo: a.committeeResolutionNo.trim(),
|
||||
resolutionDate: a.committeeResolutionDate.trim()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
approveStage(a: LoanApplication, field: ApprovalField, approverRole: string): void {
|
||||
decideApproval(
|
||||
a,
|
||||
field,
|
||||
'APPROVED',
|
||||
this.currentUserName,
|
||||
approverRole,
|
||||
this.actionRemarks.trim() || 'Reviewed and approved.'
|
||||
this.runStageAction(
|
||||
this.originationService.decideApproval(a.applicationNo, field, {
|
||||
decision: 'APPROVED',
|
||||
approverName: this.currentUserName,
|
||||
approverRole,
|
||||
remarks: 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.runStageAction(
|
||||
this.originationService.decideApproval(a.applicationNo, field, {
|
||||
decision: 'REJECTED',
|
||||
approverName: this.currentUserName,
|
||||
approverRole,
|
||||
remarks: 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.runStageAction(
|
||||
this.originationService.decideApproval(a.applicationNo, 'postCheckApproval', {
|
||||
decision: 'EXCEPTION_APPROVED',
|
||||
approverName: this.currentUserName,
|
||||
approverRole: 'Post-Disbursement Check',
|
||||
remarks: this.actionRemarks.trim() || 'Approved on exception — minor gap noted and accepted.'
|
||||
})
|
||||
);
|
||||
this.actionRemarks = '';
|
||||
}
|
||||
|
||||
legalChecklistDone(a: LoanApplication): boolean {
|
||||
@@ -2227,6 +2611,7 @@ I confirm that:
|
||||
this.guarantorShareAccounts = [];
|
||||
this.guarantorSearchText = '';
|
||||
this.guarantorSearchResults = [];
|
||||
this.persist();
|
||||
}
|
||||
|
||||
/** Same `/clients/{id}/accounts` call the applicant's own Share Member check uses -- a
|
||||
@@ -2244,6 +2629,9 @@ I confirm that:
|
||||
this.guarantorShareAccounts = data?.shareAccounts ?? [];
|
||||
a.guarantorHasShareAccount = this.guarantorShareAccounts.length > 0;
|
||||
this.loadingGuarantorShareCheck = false;
|
||||
// Saved here rather than in selectGuarantor(), so the stored record carries the verified
|
||||
// share-membership result and not just the picked name.
|
||||
this.persist();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2281,6 +2669,8 @@ I confirm that:
|
||||
|
||||
uploadCicReport(a, file.name, this.currentUserName);
|
||||
input.value = '';
|
||||
this.uploadCaptured(file, 'CIC:report', `${a.cicBureau} Credit Report`);
|
||||
this.persist();
|
||||
this.openCicReportAnalysis(a);
|
||||
}
|
||||
|
||||
@@ -2306,6 +2696,7 @@ I confirm that:
|
||||
|
||||
pushFieldInquiryNow(a: LoanApplication): void {
|
||||
pushFieldInquiry(a, 'Field Verification Team', this.currentUserName);
|
||||
this.persist();
|
||||
}
|
||||
|
||||
/** Free-text reference/summary the officer types before attaching the Agent Report file --
|
||||
@@ -2319,21 +2710,54 @@ I confirm that:
|
||||
return;
|
||||
}
|
||||
uploadAgentReport(a, file.name, this.agentReportRemarkInput.trim(), this.currentUserName);
|
||||
this.uploadCaptured(file, 'FI:agentReport', 'Field Investigation Agent Report');
|
||||
this.agentReportRemarkInput = '';
|
||||
input.value = '';
|
||||
this.persist();
|
||||
}
|
||||
|
||||
// -------------------- Signing / Cheque-ACH / Disbursement --------------------
|
||||
markSigned(a: LoanApplication): void {
|
||||
markCustomerSigned(a, a.applicantName);
|
||||
this.runStageAction(this.originationService.markSigned(a.applicationNo, a.applicantName));
|
||||
}
|
||||
|
||||
confirmChequeAndProceed(a: LoanApplication): void {
|
||||
confirmChequeAch(a, this.currentUserName);
|
||||
this.runStageAction(this.originationService.confirmChequeAch(a.applicationNo, this.currentUserName));
|
||||
}
|
||||
|
||||
disburseNow(a: LoanApplication): void {
|
||||
disburseApplication(a, this.currentUserName, this.disbursementMode);
|
||||
/** For an Existing-Customer application, this is the one point where the pipeline stops being a
|
||||
* mock checklist and actually creates the real Fineract loan account (create + approve +
|
||||
* disburse in one go, see loan-fineract-bridge.ts) -- New Customer applications keep disbursing
|
||||
* mock-only since there's no real clientId to attach a loan account to. A failure here leaves
|
||||
* the application on the Disbursement stage (nothing marked Disbursed) so the officer can fix
|
||||
* the underlying issue and retry rather than silently losing the real-account step. */
|
||||
async disburseNow(a: LoanApplication): Promise<void> {
|
||||
let realLoan: { fineractLoanId: number; fineractLoanAccountNo: string } | undefined;
|
||||
if (a.customerType === 'EXISTING' && a.clientId) {
|
||||
this.creatingRealLoan = true;
|
||||
a.fineractLoanError = null;
|
||||
try {
|
||||
const result = await createRealLoanAccount(
|
||||
a,
|
||||
this.loansService,
|
||||
this.productsService,
|
||||
this.dateUtils,
|
||||
this.settingsService
|
||||
);
|
||||
realLoan = { fineractLoanId: result.loanId, fineractLoanAccountNo: result.loanAccountNo };
|
||||
} catch (err: any) {
|
||||
a.fineractLoanError =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage || err?.message || 'Could not create the real loan account.';
|
||||
this.creatingRealLoan = false;
|
||||
return;
|
||||
}
|
||||
this.creatingRealLoan = false;
|
||||
}
|
||||
// The server re-checks that all four approvals actually stand as approved before it will record
|
||||
// the disbursement, and stores the real loan account reference in the same call.
|
||||
this.runStageAction(
|
||||
this.originationService.disburse(a.applicationNo, this.currentUserName, this.disbursementMode, realLoan)
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------- EMI Repayment Schedule (once disbursed) --------------------
|
||||
|
||||
Reference in New Issue
Block a user