diff --git a/src/app/loan-origination/application-detail/application-detail.component.html b/src/app/loan-origination/application-detail/application-detail.component.html index 9a2c29a6a..4244766f2 100644 --- a/src/app/loan-origination/application-detail/application-detail.component.html +++ b/src/app/loan-origination/application-detail/application-detail.component.html @@ -6,7 +6,14 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
+ +
+ @if (loadingApplication) { +

Loading application…

+ } @if (application; as a) {
@@ -21,6 +28,18 @@

+ @if (uploadingDocs > 0) { + + Uploading {{ uploadingDocs }} file{{ uploadingDocs > 1 ? 's' : '' }}… + + } @else if (docUploadFailed) { + File upload failed + } + @if (saving) { + Saving… + } @else if (saveFailed) { + Not saved — check connection + }
@@ -86,8 +105,8 @@
Co-Borrower
{{ a.coBorrowerName }} - @if (coBorrowerCapture.wanted && coBorrowerCapture.relationship) { - ({{ coBorrowerCapture.relationship }}) + @if (a.coBorrowerWanted && a.coBorrowerRelationship) { + ({{ a.coBorrowerRelationship }}) }
@@ -168,6 +187,16 @@
EMI Activation Confirmation
{{ a.emiActivationConfirmed ? 'Confirmed on ' + a.emiActivationDate : 'Pending' }}
+
+
Core Banking Loan Account
+
+ @if (a.fineractLoanAccountNo) { + A/C {{ a.fineractLoanAccountNo }} + } @else { + Mock disbursement only — no core banking account + } +
+
@@ -336,12 +365,14 @@ (change)="onNewSalariedProofFile(a, $event)" /> - @if (newKycCapture.incomeProof.salaried.file) { + @if (newKycCapture.incomeProof.salaried.file || hasStoredDoc('KYC:incomeProof:salaried')) { - @if (isCsvFile(newKycCapture.bankStatement.file)) { + @if (canReopenBankStatementAnalysis()) { @@ -498,12 +529,12 @@ (change)="onNewElectricityBillFile(a, $event)" /> - @if (newKycCapture.electricityBill.file) { + @if (newKycCapture.electricityBill.file || hasStoredDoc('KYC:electricityBill')) { @@ -1252,138 +1283,139 @@
CIC / Credit Bureau Check — {{ a.cicBureau }}
- @if (a.cibilScore === 0) { -
-
Customer Consent — Credit Bureau Check
-

- RBI's Fair Practices Code requires the customer's signed consent before a credit bureau pull. Generate - the declaration letter below, get it signed by the customer, then upload the signed copy. -

-
- @if (a.channel === 'BRANCH') { - - } @else { - - - - - } - + +
+
Customer Consent — Credit Bureau Check
+

+ RBI's Fair Practices Code requires the customer's signed consent before a credit bureau pull. Generate + the declaration letter below, get it signed by the customer, then upload the signed copy. +

+
+ @if (a.channel === 'BRANCH') { + + } @else { + + + + + } + +
+ @if (cicConsentCapture.file || hasStoredDoc('CIC:consent')) { +
+
- @if (cicConsentCapture.file) { -
+ } + @if (a.cicConsentSigned) { + Signed Declaration Uploaded + } @else { + Awaiting Signed Declaration + } +
+ +
+
CIC Check Charge — ₹{{ cicCheckCharge }}
+ @if (a.clientId && realDebitableAccounts.length > 0) { + +

+ Select which account to debit the ₹{{ cicCheckCharge }} charge from: +

+
+ @for (acc of realDebitableAccounts; track acc.id) { -
- } - @if (a.cicConsentSigned) { - Signed Declaration Uploaded + } +
+ } @else { + + @if (a.clientId && loadingShareAccounts) { +

Loading accounts…

} @else { - Awaiting Signed Declaration - } -
- -
-
CIC Check Charge — ₹{{ cicCheckCharge }}
- @if (a.clientId && realDebitableAccounts.length > 0) { - -

- Select which account to debit the ₹{{ cicCheckCharge }} charge from: -

-
- @for (acc of realDebitableAccounts; track acc.id) { - - } -
- } @else { - - @if (a.clientId && loadingShareAccounts) { -

Loading accounts…

+
} @else { - @if (a.clientId) { -

- No open savings/current account on file for this customer — collect the charge via payment link - instead. -

- } - @if (!a.cicPaymentLinkUrl || a.cicPaymentLinkUrl === '—') { +

{{ a.cicPaymentLinkUrl }}

+
+ + + +
+ @if (!a.cicChargeCollected) { +
-
- } @else { -

{{ a.cicPaymentLinkUrl }}

-
- - - -
- @if (!a.cicChargeCollected) { - -
- -
- } } } } - @if (a.cicChargeCollected) { - - Charge Collected — - {{ a.cicChargeMode === 'ACCOUNT_DEBIT' ? a.cicChargeAccountLabel : 'Ref ' + a.cicPaymentReference }} - - } @else { - Charge Not Collected - } -
+ } + @if (a.cicChargeCollected) { + + Charge Collected — + {{ a.cicChargeMode === 'ACCOUNT_DEBIT' ? a.cicChargeAccountLabel : 'Ref ' + a.cicPaymentReference }} + + } @else { + Charge Not Collected + } +
+ @if (a.cibilScore === 0) {
@@ -1642,7 +1670,12 @@
@@ -1735,6 +1768,13 @@
}
+ @if (a.agentReportUploaded && hasStoredDoc('FI:agentReport')) { +
+ +
+ } @if (!a.agentReportUploaded) {
} + + @for (stored of storedDocsFor('LEGAL_DOC:' + doc.key); track stored.id) { + + } @if (doc.submitted) { @@ -2341,10 +2402,30 @@

Disbursing also confirms EMI Activation for this account in the same step. + @if (a.customerType === 'EXISTING' && a.clientId) { + A real Fineract loan account will be created, approved and disbursed in Core Banking at the same time. + } @else { + This is a mock disbursement only — no Core Banking account exists until this customer is fully onboarded + as a client. + }

+ @if (a.fineractLoanError) { +

{{ a.fineractLoanError }}

+ }
-
diff --git a/src/app/loan-origination/application-detail/application-detail.component.ts b/src/app/loan-origination/application-detail/application-detail.component.ts index 600fd8057..9a75f67e0 100644 --- a/src/app/loan-origination/application-detail/application-detail.component.ts +++ b/src/app/loan-origination/application-detail/application-detail.component.ts @@ -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 | 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 = { 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 { @@ -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): 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 { @@ -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 { + 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) -------------------- diff --git a/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.html b/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.html index 92d8e32d6..4bcc4acdc 100644 --- a/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.html +++ b/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.html @@ -38,24 +38,43 @@

Site Photos ({{ data.check.photoCount }})

- @if (data.photos.length > 0) { + @if (data.photos.length > 0 || data.storedPhotos.length > 0) {
@for (photo of data.photos; track $index) { -
+
+ + } + @for (stored of data.storedPhotos; track stored.id) { + }
} @else { -

- No photo files available in this browser session -- they were captured earlier and aren't retained after a page - refresh, though the count above is. -

+

No photos on file for this visit.

} +
diff --git a/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.scss b/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.scss index 01dade83b..f05fbd028 100644 --- a/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.scss +++ b/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.scss @@ -90,6 +90,10 @@ align-items: center; justify-content: center; background: #f8fafc; + padding: 0; + margin: 0; + font: inherit; + cursor: default; img { width: 100%; @@ -98,6 +102,26 @@ } } +.fvd-photo--stored { + &:hover { + border-color: #94a3b8; + background: #f1f5f9; + } +} + +// Any tile that opens the zoomable lightbox on click -- local captures and stored photos alike. +.fvd-photo--zoomable { + cursor: zoom-in; + + &:hover { + border-color: #94a3b8; + } +} + +.fvd-lightbox-root { + display: none; +} + .fvd-photo-label { font-size: 11px; color: #64748b; diff --git a/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.ts b/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.ts index bf194628a..1822c5164 100644 --- a/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.ts +++ b/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.ts @@ -6,10 +6,15 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, inject } from '@angular/core'; +import { Component, ElementRef, OnDestroy, ViewChild, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; import { MatButtonModule } from '@angular/material/button'; +import lightGallery from 'lightgallery'; +import lgZoom from 'lightgallery/plugins/zoom'; +import lgFullscreen from 'lightgallery/plugins/fullscreen'; +import type { LightGallery } from 'lightgallery/lightgallery'; +import type { GalleryItem } from 'lightgallery/lg-utils'; import { VerificationCheck } from '../../loan-origination-data'; export interface FieldVisitPhoto { @@ -21,15 +26,18 @@ export interface FieldVisitDetailsDialogData { label: string; check: VerificationCheck; photos: FieldVisitPhoto[]; + /** Photos already in the document store, i.e. captured in an earlier session -- `photos` above + * only ever holds files picked in the current browser tab. `previewUrl` is filled in by the + * caller (downloaded up front, in parallel) so these render as real thumbnails, not just a + * file-name placeholder; it's `null` only if that download failed. */ + storedPhotos: { id: number; fileName?: string; previewUrl: string | null }[]; + onViewStored: (doc: { id: number; fileName?: string }) => void; } /** * Everything saved for one Field Investigation address visit -- officer, assignment date, GPS, * status/remarks and every site photo -- in one popup, since the inline card only ever shows a - * short summary once the visit is Verified/Failed. Photos are local-only File objects (same as - * every other document capture in this mock pipeline), so a photo count with no files behind it - * just means the page was refreshed since the visit was captured, not that anything was lost from - * the application record itself. + * short summary once the visit is Verified/Failed. */ @Component({ selector: 'mifosx-field-visit-details-dialog', @@ -42,9 +50,16 @@ export interface FieldVisitDetailsDialogData { templateUrl: './field-visit-details-dialog.component.html', styleUrls: ['./field-visit-details-dialog.component.scss'] }) -export class FieldVisitDetailsDialogComponent { +export class FieldVisitDetailsDialogComponent implements OnDestroy { data = inject(MAT_DIALOG_DATA); + @ViewChild('lightboxRoot', { static: true }) lightboxRoot: ElementRef; + private lightboxInstance: LightGallery | null = null; + private readonly lightboxPlugins = [ + lgZoom, + lgFullscreen + ]; + get statusBadgeClass(): string { if (this.data.check.status === 'VERIFIED') { return 'fvd-badge fvd-badge--verified'; @@ -54,4 +69,63 @@ export class FieldVisitDetailsDialogComponent { } return 'fvd-badge fvd-badge--pending'; } + + /** Every viewable photo -- local captures first, then stored ones -- in the same order the grid + * renders them. `openPhotoZoom` locates the clicked thumbnail in here by URL, not position, so + * this list can freely skip photos with no preview without throwing anything out of sync. */ + private get galleryItems(): GalleryItem[] { + const local = this.data.photos + .filter((p) => !!p.previewUrl) + .map((p, i) => ({ + src: p.previewUrl as string, + subHtml: `

Site photo ${i + 1}

` + })); + const stored = this.data.storedPhotos + .filter((p) => !!p.previewUrl) + .map((p) => ({ + src: p.previewUrl as string, + subHtml: `

${p.fileName || 'Site photo'}

` + })); + return [ + ...local, + ...stored + ]; + } + + /** Opens the site photos in the same zoomable, pannable lightbox used for cheque/signature + * images app-wide, starting on whichever thumbnail was clicked -- matched by preview URL rather + * than array index, since `galleryItems` skips any photo without one (e.g. a failed stored + * download), which would otherwise throw the two lists out of sync. */ + openPhotoZoom(previewUrl: string | null): void { + if (!previewUrl) { + return; + } + const items = this.galleryItems; + const startIndex = items.findIndex((item) => item.src === previewUrl); + if (startIndex < 0) { + return; + } + this.destroyLightbox(); + this.lightboxInstance = lightGallery(this.lightboxRoot.nativeElement, { + dynamic: true, + dynamicEl: items, + plugins: this.lightboxPlugins, + download: false, + closable: true, + escKey: true, + zoomFromOrigin: true + }); + this.lightboxInstance.openGallery(startIndex); + } + + private destroyLightbox(): void { + if (this.lightboxInstance) { + this.lightboxInstance.destroy(); + this.lightboxInstance = null; + } + } + + ngOnDestroy(): void { + this.destroyLightbox(); + } } diff --git a/src/app/loan-origination/loan-fineract-bridge.ts b/src/app/loan-origination/loan-fineract-bridge.ts new file mode 100644 index 000000000..a74c8d3b1 --- /dev/null +++ b/src/app/loan-origination/loan-fineract-bridge.ts @@ -0,0 +1,101 @@ +/** + * 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 { firstValueFrom } from 'rxjs'; +import { Dates } from '../core/utils/dates'; +import { LoansService } from '../loans/loans.service'; +import { ProductsService } from '../products/products.service'; +import { SettingsService } from '../settings/settings.service'; +import { LoanApplication, ORIGINATION_PRODUCTS } from './loan-origination-data'; + +export interface RealLoanCreationResult { + loanId: number; + loanAccountNo: string; +} + +const cleanName = (name: string): string => name.trim().toLowerCase().replace(/\s+/g, ' '); + +/** Creates, approves and disburses a real Fineract loan account for an Existing-Customer + * application at the moment an officer disburses it -- see loan-fineract-bridge plan for why this + * only fires at final disbursement rather than at wizard-submit or Committee sanction. Every + * mandatory Fineract field this mock pipeline never collected (loan purpose, interest type, + * amortization type, interest calculation period, repayment strategy) is pulled from the real + * loan product's own template rather than guessed, so it's always valid for whatever that + * Fineract instance actually has configured. */ +export async function createRealLoanAccount( + app: LoanApplication, + loansService: LoansService, + productsService: ProductsService, + dateUtils: Dates, + settings: SettingsService +): Promise { + const mockProduct = ORIGINATION_PRODUCTS.find((p) => p.code === app.productCode); + const wantedName = cleanName(mockProduct?.name || app.productName); + + const products: any[] = (await firstValueFrom(productsService.getLoanProducts())) || []; + const matchedProduct = products.find((p) => cleanName(p.name) === wantedName); + if (!matchedProduct) { + throw new Error( + `No Fineract loan product named "${mockProduct?.name || app.productName}" was found -- ask an admin to configure one before disbursing.` + ); + } + + const template: any = await firstValueFrom(productsService.getLoanProduct(String(matchedProduct.id), true)); + const repaymentEvery: number = template.repaymentEvery || 1; + + const locale = settings.language.code; + const dateFormat = settings.dateFormat; + const today = dateUtils.formatDate(new Date(), dateFormat); + + const payload = { + clientId: app.clientId, + productId: matchedProduct.id, + loanType: 'individual', + principal: app.requestedAmount, + loanTermFrequency: app.tenureMonths, + loanTermFrequencyType: 2, // Months + numberOfRepayments: Math.max(1, Math.round(app.tenureMonths / repaymentEvery)), + repaymentEvery, + repaymentFrequencyType: template.repaymentFrequencyType?.id, + interestRatePerPeriod: app.interestRate, + interestType: template.interestType?.id, + interestCalculationPeriodType: template.interestCalculationPeriodType?.id, + amortizationType: template.amortizationType?.id, + transactionProcessingStrategyCode: template.transactionProcessingStrategyCode, + submittedOnDate: today, + expectedDisbursementDate: today, + dateFormat, + locale + }; + + const created: any = await firstValueFrom(loansService.createLoansAccount(payload)); + const loanId: number = created.loanId ?? created.resourceId; + + await firstValueFrom( + loansService.loanActionButtons(loanId, 'approve', { + approvedOnDate: today, + dateFormat, + locale + }) + ); + + await firstValueFrom( + loansService.loanActionButtons(loanId, 'disburse', { + actualDisbursementDate: today, + transactionAmount: app.requestedAmount, + dateFormat, + locale + }) + ); + + const account: any = await firstValueFrom(loansService.getLoanAccountResource(String(loanId), '')); + return { + loanId, + loanAccountNo: account?.accountNo || String(loanId) + }; +} diff --git a/src/app/loan-origination/loan-origination-data.ts b/src/app/loan-origination/loan-origination-data.ts index 3bdcbf317..34569a52a 100644 --- a/src/app/loan-origination/loan-origination-data.ts +++ b/src/app/loan-origination/loan-origination-data.ts @@ -181,6 +181,9 @@ export interface StageHistoryEntry { } export interface LoanApplication { + /** Database row id, assigned on save. Uploaded files hang off this (documents are stored against + * the application's entity id), so it's absent only on an application not yet persisted. */ + id?: number; applicationNo: string; applicationDate: string; applicantName: string; @@ -225,6 +228,12 @@ export interface LoanApplication { eligibleLoanAmount: number; coBorrowerRequired: boolean; coBorrowerName: string; + /** Officer's voluntary opt-in to add a co-borrower even when `coBorrowerRequired` hasn't fired + * on its own -- separate from that flag so a required co-borrower stays flagged even if this + * is later unticked. */ + coBorrowerWanted: boolean; + coBorrowerRelationship: string; + coBorrowerMonthlyIncome: number; // CIC / Credit Bureau check — mandatory before sanction under the CICRA, 2005 framework. /** RBI's Fair Practices Code requires the customer's informed consent before a credit bureau @@ -354,6 +363,14 @@ export interface LoanApplication { achConfirmationRef: string; emiActivationConfirmed: boolean; emiActivationDate: string; + /** Set only when a real Fineract loan account was actually created at disbursement (Existing + * Customer applications only, see loan-fineract-bridge.ts) -- null for every mock application + * and for New Customer applications, which disburse mock-only. */ + fineractLoanId: number | null; + fineractLoanAccountNo: string | null; + /** Last error from attempting real loan creation, so the officer can see why and retry -- + * cleared on every fresh attempt, never fabricated as a success. */ + fineractLoanError: string | null; currentStage: OriginationStage; /** The furthest stage this application has ever reached, as a STAGE_ORDER index -- kept @@ -936,6 +953,9 @@ function generateApplications(count: number): LoanApplication[] { eligibleLoanAmount, coBorrowerRequired: eligibleLoanAmount < requestedAmount, coBorrowerName: '—', + coBorrowerWanted: false, + coBorrowerRelationship: '', + coBorrowerMonthlyIncome: 0, cicConsentSigned: false, cicChargeCollected: false, cicChargeMode: null, @@ -1011,6 +1031,9 @@ function generateApplications(count: number): LoanApplication[] { achConfirmationRef: '—', emiActivationConfirmed: false, emiActivationDate: '—', + fineractLoanId: null, + fineractLoanAccountNo: null, + fineractLoanError: null, currentStage: 'APPLICATION', furthestStageIndex: 0, overallStatus: 'IN_PROGRESS', @@ -1388,9 +1411,15 @@ function generateApplications(count: number): LoanApplication[] { 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); +/** Builds the demo book that seeds an empty pipeline on first use — sized like a real cooperative + * bank's active sanctioning queue: enough to need filters and pagination, not so many it stops + * reading as "an active queue". + * + * Called once, to POST to the seed endpoint. Applications are read back from the database after + * that, so this is a one-time generator and no longer the live source of truth. */ +export function generateSeedApplications(count = 140): LoanApplication[] { + return generateApplications(count); +} // ------------------------------------------------------------------------- // Runtime actions — every button in the Application Detail screen calls one of these to mutate @@ -1787,7 +1816,7 @@ export function buildEmiSchedule( // ------------------------------------------------------------------------- // 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. +// posts it to the database, exactly at the APPLICATION stage with everything else pending. // ------------------------------------------------------------------------- export interface NewApplicationInput { channel: Channel; @@ -1839,6 +1868,9 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan eligibleLoanAmount: 0, coBorrowerRequired: false, coBorrowerName: '—', + coBorrowerWanted: false, + coBorrowerRelationship: '', + coBorrowerMonthlyIncome: 0, cicConsentSigned: false, cicChargeCollected: false, cicChargeMode: null, @@ -1914,6 +1946,9 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan achConfirmationRef: '—', emiActivationConfirmed: false, emiActivationDate: '—', + fineractLoanId: null, + fineractLoanAccountNo: null, + fineractLoanError: null, currentStage: 'APPLICATION', furthestStageIndex: 0, overallStatus: 'IN_PROGRESS', diff --git a/src/app/loan-origination/loan-origination.service.ts b/src/app/loan-origination/loan-origination.service.ts new file mode 100644 index 000000000..cf1317b68 --- /dev/null +++ b/src/app/loan-origination/loan-origination.service.ts @@ -0,0 +1,201 @@ +/** + * 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 { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { LoanApplication } from './loan-origination-data'; + +/** + * Persistence for the Loan Origination Pipeline, in two deliberately different shapes. + * + * `save()` sends the whole application object. That covers the dozens of inline fields an officer + * types across the stages: there is no field list to keep in step with the model, so a field added + * later persists without touching this service or the backend. + * + * The stage methods (`completeKyc`, `decideApproval`, `verifyFieldCheck`, `disburse`, …) send only + * that step's own inputs and let the server apply the transition. Sanctioning decisions and + * verifications go through those instead of `save()` because the client should not be able to simply + * assert them — the server checks each stage's prerequisites against the stored record first. + */ +@Injectable({ providedIn: 'root' }) +export class LoanOriginationService { + private readonly http = inject(HttpClient); + + private readonly baseUrl = '/loan-origination/applications'; + + list(): Observable { + return this.http.get(this.baseUrl); + } + + get(applicationNo: string): Observable { + return this.http.get(`${this.baseUrl}/${encodeURIComponent(applicationNo)}`); + } + + create(application: LoanApplication): Observable { + return this.http.post(this.baseUrl, application); + } + + save(application: LoanApplication): Observable { + return this.http.put( + `${this.baseUrl}/${encodeURIComponent(application.applicationNo)}`, + application + ); + } + + /** Seeds the demo book on first use. The backend ignores this once the pipeline is non-empty, so + * calling it again after a reload can't duplicate anything. */ + seed(applications: LoanApplication[]): Observable<{ seeded: number }> { + return this.http.post<{ seeded: number }>(`${this.baseUrl}/seed`, applications); + } + + // -------------------- Stage transitions -------------------- + // Each of these posts only its own step's inputs and lets the server validate that stage's + // prerequisites against the stored record before applying the change. The response is the updated + // application, which the caller uses in place of its own copy -- so the screen always shows what + // the server actually accepted rather than what the client assumed. + // + // The full-object save() above is still used for inline field edits; these cover the decisions + // (approvals, verifications, disbursement) where "the client said so" is not good enough. + + private stage(applicationNo: string, path: string, body: unknown = {}): Observable { + return this.http.post(`${this.baseUrl}/${encodeURIComponent(applicationNo)}/${path}`, body); + } + + completeKyc(applicationNo: string, actionBy: string, remarks?: string): Observable { + return this.stage(applicationNo, 'kyc-complete', { actionBy, remarks }); + } + + completeCicCheck(applicationNo: string, actionBy: string, remarks?: string): Observable { + return this.stage(applicationNo, 'cic-complete', { actionBy, remarks }); + } + + verifyFieldCheck( + applicationNo: string, + checkKey: string, + verifiedBy: string, + remarks?: string + ): Observable { + return this.stage(applicationNo, `field-investigation/${checkKey}/verify`, { verifiedBy, remarks }); + } + + rejectFieldCheck( + applicationNo: string, + checkKey: string, + verifiedBy: string, + remarks: string + ): Observable { + return this.stage(applicationNo, `field-investigation/${checkKey}/reject`, { verifiedBy, remarks }); + } + + completeFieldInvestigation(applicationNo: string, actionBy: string, remarks?: string): Observable { + return this.stage(applicationNo, 'field-investigation/complete', { actionBy, remarks }); + } + + clearPersonalDiscussion(applicationNo: string, actionBy: string, remarks?: string): Observable { + return this.stage(applicationNo, 'personal-discussion/clear', { actionBy, remarks }); + } + + flagDiscrepancy( + applicationNo: string, + actionBy: string, + discrepancyType: string, + remarks: string + ): Observable { + return this.stage(applicationNo, 'personal-discussion/flag', { actionBy, discrepancyType, remarks }); + } + + resolveDiscrepancy(applicationNo: string, actionBy: string, remarks?: string): Observable { + return this.stage(applicationNo, 'personal-discussion/resolve', { actionBy, remarks }); + } + + decideApproval( + applicationNo: string, + field: string, + body: { + decision: 'APPROVED' | 'REJECTED' | 'EXCEPTION_APPROVED'; + approverName: string; + approverRole: string; + remarks?: string; + resolutionNo?: string; + resolutionDate?: string; + } + ): Observable { + return this.stage(applicationNo, `approvals/${field}`, body); + } + + markSigned(applicationNo: string, actionBy: string): Observable { + return this.stage(applicationNo, 'signing', { actionBy }); + } + + confirmChequeAch(applicationNo: string, actionBy: string): Observable { + return this.stage(applicationNo, 'cheque-ach', { actionBy }); + } + + disburse( + applicationNo: string, + actionBy: string, + disbursementMode: string, + fineractLoan?: { fineractLoanId: number; fineractLoanAccountNo: string } + ): Observable { + return this.stage(applicationNo, 'disburse', { actionBy, disbursementMode, ...(fineractLoan ?? {}) }); + } + + /** Field officers to assign during Field Investigation, pulled from the CBS's real staff + * register (`/staff`) instead of letting an officer's name be freely typed -- restricted to + * loan officers via `loanOfficersOnly` (the actual backend query param -- note this differs + * from `OrganizationService.getStaff`, which sends the mismatched `isLoanOfficer` key and so + * never actually filters). No `officeId` filter, so this is every active loan officer visible + * in the caller's own office hierarchy, same authorization the endpoint already applies + * server-side. */ + listStaff(): Observable<{ id: number; displayName: string }[]> { + return this.http.get<{ id: number; displayName: string }[]>('/staff', { + params: { status: 'active', loanOfficersOnly: 'true' } + }); + } + + // -------------------- Uploaded documents -------------------- + // Files go through Fineract's own generic document store (`m_document`, keyed by entity type and + // id) using the same multipart shape as every other upload in this app -- see + // ClientsService.uploadClientSignatureImage(). That means loan-origination attachments land in the + // same configured storage (filesystem/S3) as client and loan documents rather than in a private + // table of their own, and the standard attachment download works on them unchanged. + + /** Entity type these documents are filed under in `m_document.parent_entity_type`. */ + private readonly documentEntity = 'loanoriginationapplications'; + + private documentsUrl(applicationId: number): string { + return `/${this.documentEntity}/${applicationId}/documents`; + } + + /** + * @param name Stable key identifying which checklist slot this file belongs to (see + * `LoanOriginationDocKey` usage in the Application Detail screen) -- this is what lets a file + * be matched back to its document row after a page reload. + * @param description Human-readable label shown to the officer. + */ + uploadDocument(applicationId: number, file: File, name: string, description: string): Observable { + const formData = new FormData(); + formData.append('file', file); + formData.append('name', name); + formData.append('description', description); + return this.http.post(this.documentsUrl(applicationId), formData); + } + + listDocuments(applicationId: number): Observable { + return this.http.get(this.documentsUrl(applicationId)); + } + + downloadDocument(applicationId: number, documentId: number): Observable { + return this.http.get(`${this.documentsUrl(applicationId)}/${documentId}/attachment`, { responseType: 'blob' }); + } + + deleteDocument(applicationId: number, documentId: number): Observable { + return this.http.delete(`${this.documentsUrl(applicationId)}/${documentId}`); + } +} diff --git a/src/app/loan-origination/loan-pipeline/loan-pipeline.component.html b/src/app/loan-origination/loan-pipeline/loan-pipeline.component.html index 83e1d4db7..44b124f24 100644 --- a/src/app/loan-origination/loan-pipeline/loan-pipeline.component.html +++ b/src/app/loan-origination/loan-pipeline/loan-pipeline.component.html @@ -348,7 +348,14 @@ }
- +
} diff --git a/src/app/loan-origination/loan-pipeline/loan-pipeline.component.ts b/src/app/loan-origination/loan-pipeline/loan-pipeline.component.ts index 8bcdb5103..100103041 100644 --- a/src/app/loan-origination/loan-pipeline/loan-pipeline.component.ts +++ b/src/app/loan-origination/loan-pipeline/loan-pipeline.component.ts @@ -13,6 +13,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { AuthenticationService } from '../../core/authentication/authentication.service'; import { ClientsService } from '../../clients/clients.service'; import { ExportSheetSpec, exportWorkbook } from '../../cashier/cashier-export'; +import { LoanOriginationService } from '../loan-origination.service'; import { BRANCHES, BranchMaster, @@ -21,8 +22,8 @@ import { CHANNEL_LABELS, CHANNEL_TARGET_PCT, CustomerType, + generateSeedApplications, LoanApplication, - LOAN_APPLICATIONS, ORIGINATION_PRODUCTS, OriginationStage, OverallStatus, @@ -99,9 +100,8 @@ type ViewId = 'pipeline' | 'new' | 'channel' | 'bc' | 'share' | 'target' | 'rate * 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. + * intake form. Every count/total here is derived from the same applications loaded from the + * database, so the pipeline view can never drift from an application's real state. */ @Component({ selector: 'mifosx-loan-pipeline', @@ -188,6 +188,13 @@ export class LoanPipelineComponent implements OnInit { private readonly router = inject(Router); private readonly auth = inject(AuthenticationService); private readonly clientsService = inject(ClientsService); + private readonly originationService = inject(LoanOriginationService); + + /** The live book, loaded from the database. Every count, report and filter on this screen derives + * from this one array, so the list can never disagree with what an application actually says. */ + applications: LoanApplication[] = []; + loadingApplications = false; + savingNewApplication = false; ngOnInit(): void { const requestedView = this.route.snapshot.queryParamMap.get('view') as ViewId | null; @@ -225,6 +232,52 @@ export class LoanPipelineComponent implements OnInit { // not just hidden, since the New Application form posts whatever branchCode is bound. this.newApp.branchCode = this.branchFilter; } + + this.loadApplications(); + } + + /** Loads the book from the database. On a completely fresh install the pipeline is empty, so the + * demo book is seeded once and then re-read — the seed endpoint no-ops when rows already exist, + * so reloading this screen can never duplicate it. */ + private loadApplications(): void { + this.loadingApplications = true; + this.originationService.list().subscribe({ + next: (apps: LoanApplication[]) => { + if (apps.length === 0) { + this.seedThenReload(); + return; + } + this.applications = apps; + this.loadingApplications = false; + }, + error: () => { + this.applications = []; + this.loadingApplications = false; + this.toast('Could not load the loan origination pipeline.'); + } + }); + } + + private seedThenReload(): void { + this.originationService.seed(generateSeedApplications()).subscribe({ + next: () => { + this.originationService.list().subscribe({ + next: (seeded: LoanApplication[]) => { + this.applications = seeded; + this.loadingApplications = false; + }, + error: () => { + this.applications = []; + this.loadingApplications = false; + } + }); + }, + error: () => { + this.applications = []; + this.loadingApplications = false; + this.toast('Could not initialise the loan origination pipeline.'); + } + }); } /** Branches selectable on the New Application form — every branch for Head Office, only the @@ -254,7 +307,7 @@ export class LoanPipelineComponent implements OnInit { } get scopedApplications(): LoanApplication[] { - return this.branchFilter ? LOAN_APPLICATIONS.filter((a) => a.branchCode === this.branchFilter) : LOAN_APPLICATIONS; + return this.branchFilter ? this.applications.filter((a) => a.branchCode === this.branchFilter) : this.applications; } get filteredApplications(): LoanApplication[] { @@ -407,10 +460,9 @@ export class LoanPipelineComponent implements OnInit { ? 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 actual = this.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 { @@ -558,7 +610,7 @@ export class LoanPipelineComponent implements OnInit { } 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 seq = this.applications.length + 20000; const app = createApplication( { channel: this.newApp.channel, @@ -578,26 +630,40 @@ export class LoanPipelineComponent implements OnInit { }, seq ); - LOAN_APPLICATIONS.unshift(app); - this.toast(`Application ${app.applicationNo} received for ${app.applicantName}.`); - this.newApp = { - channel: 'BRANCH', - bcAgentName: '', - customerType: 'NEW', - applicantName: '', - clientId: null, - branchCode: this.isHeadOffice ? this.branches[0].code : this.ownBranchCode || this.branches[0].code, - productCode: this.products[0].code, - productType: defaultProductType(this.products[0].code), - requestedAmount: null, - tenureMonths: null - }; - this.existingClientSearchText = ''; - this.existingClientSearchResults = []; - this.router.navigate([ - '/loan-origination/application', - app.applicationNo - ]); + // Only navigate once the application is actually stored -- an officer who lands on the detail + // screen for something that failed to save would fill in a whole stage against nothing. + this.savingNewApplication = true; + this.originationService.create(app).subscribe({ + next: (saved: LoanApplication) => { + this.savingNewApplication = false; + this.applications.unshift(saved); + this.toast(`Application ${saved.applicationNo} received for ${saved.applicantName}.`); + this.newApp = { + channel: 'BRANCH', + bcAgentName: '', + customerType: 'NEW', + applicantName: '', + clientId: null, + branchCode: this.isHeadOffice ? this.branches[0].code : this.ownBranchCode || this.branches[0].code, + productCode: this.products[0].code, + productType: defaultProductType(this.products[0].code), + requestedAmount: null, + tenureMonths: null + }; + this.existingClientSearchText = ''; + this.existingClientSearchResults = []; + this.router.navigate([ + '/loan-origination/application', + saved.applicationNo + ]); + }, + error: (err: any) => { + this.savingNewApplication = false; + this.toast( + err?.error?.errors?.[0]?.defaultUserMessage || 'Could not save the application. Nothing was recorded.' + ); + } + }); } // -------------------- Export to Excel --------------------