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:
@@ -6,7 +6,14 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="pg">
|
||||
<!-- (input)/(change) are bound once here rather than per field: both bubble, and Angular's own
|
||||
ngModel listener sits on the input itself, so by the time this fires the model already holds
|
||||
the new value. Any inline field on this screen — including ones added later — therefore saves
|
||||
without needing its own handler wired up. -->
|
||||
<div class="pg" (input)="onInlineEdit()" (change)="onInlineEdit()">
|
||||
@if (loadingApplication) {
|
||||
<p class="pg-sub">Loading application…</p>
|
||||
}
|
||||
@if (application; as a) {
|
||||
<header class="pg-head">
|
||||
<div>
|
||||
@@ -21,6 +28,18 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
@if (uploadingDocs > 0) {
|
||||
<span class="loan-badge loan-badge--progress">
|
||||
Uploading {{ uploadingDocs }} file{{ uploadingDocs > 1 ? 's' : '' }}…
|
||||
</span>
|
||||
} @else if (docUploadFailed) {
|
||||
<span class="loan-badge loan-badge--rejected">File upload failed</span>
|
||||
}
|
||||
@if (saving) {
|
||||
<span class="loan-badge loan-badge--progress">Saving…</span>
|
||||
} @else if (saveFailed) {
|
||||
<span class="loan-badge loan-badge--rejected">Not saved — check connection</span>
|
||||
}
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="goBack()">‹ Back to Pipeline</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -86,8 +105,8 @@
|
||||
<dt>Co-Borrower</dt>
|
||||
<dd>
|
||||
{{ a.coBorrowerName }}
|
||||
@if (coBorrowerCapture.wanted && coBorrowerCapture.relationship) {
|
||||
({{ coBorrowerCapture.relationship }})
|
||||
@if (a.coBorrowerWanted && a.coBorrowerRelationship) {
|
||||
({{ a.coBorrowerRelationship }})
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
@@ -168,6 +187,16 @@
|
||||
<dt>EMI Activation Confirmation</dt>
|
||||
<dd>{{ a.emiActivationConfirmed ? 'Confirmed on ' + a.emiActivationDate : 'Pending' }}</dd>
|
||||
</div>
|
||||
<div class="loan-kv-field">
|
||||
<dt>Core Banking Loan Account</dt>
|
||||
<dd>
|
||||
@if (a.fineractLoanAccountNo) {
|
||||
<span class="loan-badge loan-badge--verified">A/C {{ a.fineractLoanAccountNo }}</span>
|
||||
} @else {
|
||||
<span class="pg-sub">Mock disbursement only — no core banking account</span>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@@ -336,12 +365,14 @@
|
||||
(change)="onNewSalariedProofFile(a, $event)"
|
||||
/>
|
||||
</label>
|
||||
@if (newKycCapture.incomeProof.salaried.file) {
|
||||
@if (newKycCapture.incomeProof.salaried.file || hasStoredDoc('KYC:incomeProof:salaried')) {
|
||||
<button
|
||||
type="button"
|
||||
class="loi-kyc-thumb"
|
||||
title="View document"
|
||||
(click)="viewNewSingleDoc(newKycCapture.incomeProof.salaried.file)"
|
||||
(click)="
|
||||
viewKycSlot(a, newKycCapture.incomeProof.salaried.file, 'KYC:incomeProof:salaried')
|
||||
"
|
||||
>
|
||||
@if (newKycCapture.incomeProof.salaried.previewUrl) {
|
||||
<img [src]="newKycCapture.incomeProof.salaried.previewUrl" alt="Salary proof" />
|
||||
@@ -367,12 +398,12 @@
|
||||
(change)="onNewItrFile(a, $index, $event)"
|
||||
/>
|
||||
</label>
|
||||
@if (doc.file) {
|
||||
@if (doc.file || hasStoredDoc('KYC:incomeProof:itr:' + $index)) {
|
||||
<button
|
||||
type="button"
|
||||
class="loi-kyc-thumb"
|
||||
title="View document"
|
||||
(click)="viewNewSingleDoc(doc.file)"
|
||||
(click)="viewKycSlot(a, doc.file, 'KYC:incomeProof:itr:' + $index)"
|
||||
>
|
||||
@if (doc.previewUrl) {
|
||||
<img [src]="doc.previewUrl" alt="ITR Year {{ $index + 1 }}" />
|
||||
@@ -436,12 +467,12 @@
|
||||
(change)="onNewBankStatementFile(a, $event)"
|
||||
/>
|
||||
</label>
|
||||
@if (newKycCapture.bankStatement.file) {
|
||||
@if (newKycCapture.bankStatement.file || hasStoredDoc('KYC:bankStatement')) {
|
||||
<button
|
||||
type="button"
|
||||
class="loi-kyc-thumb"
|
||||
title="View document"
|
||||
(click)="viewNewSingleDoc(newKycCapture.bankStatement.file)"
|
||||
(click)="viewKycSlot(a, newKycCapture.bankStatement.file, 'KYC:bankStatement')"
|
||||
>
|
||||
@if (newKycCapture.bankStatement.previewUrl) {
|
||||
<img [src]="newKycCapture.bankStatement.previewUrl" alt="Bank statement" />
|
||||
@@ -449,12 +480,12 @@
|
||||
<span class="loi-kyc-thumb-label">View file</span>
|
||||
}
|
||||
</button>
|
||||
@if (isCsvFile(newKycCapture.bankStatement.file)) {
|
||||
@if (canReopenBankStatementAnalysis()) {
|
||||
<button
|
||||
type="button"
|
||||
class="loi-bsa-reopen"
|
||||
title="Re-open the transaction analysis"
|
||||
(click)="reopenBankStatementAnalysis(newKycCapture.bankStatement.file)"
|
||||
(click)="reopenBankStatementAnalysisFor(a)"
|
||||
>
|
||||
📊 Re-check Analysis
|
||||
</button>
|
||||
@@ -498,12 +529,12 @@
|
||||
(change)="onNewElectricityBillFile(a, $event)"
|
||||
/>
|
||||
</label>
|
||||
@if (newKycCapture.electricityBill.file) {
|
||||
@if (newKycCapture.electricityBill.file || hasStoredDoc('KYC:electricityBill')) {
|
||||
<button
|
||||
type="button"
|
||||
class="loi-kyc-thumb"
|
||||
title="View document"
|
||||
(click)="viewNewSingleDoc(newKycCapture.electricityBill.file)"
|
||||
(click)="viewKycSlot(a, newKycCapture.electricityBill.file, 'KYC:electricityBill')"
|
||||
>
|
||||
@if (newKycCapture.electricityBill.previewUrl) {
|
||||
<img [src]="newKycCapture.electricityBill.previewUrl" alt="Electricity bill" />
|
||||
@@ -949,12 +980,12 @@
|
||||
/>
|
||||
</label>
|
||||
@if (productDocCapture[doc.key]; as capture) {
|
||||
@if (capture.file) {
|
||||
@if (capture.file || hasStoredDoc('PRODUCT_DOC:' + doc.key)) {
|
||||
<button
|
||||
type="button"
|
||||
class="loi-kyc-thumb"
|
||||
title="View document"
|
||||
(click)="viewNewSingleDoc(capture.file)"
|
||||
(click)="viewKycSlot(a, capture.file, 'PRODUCT_DOC:' + doc.key)"
|
||||
>
|
||||
@if (capture.previewUrl) {
|
||||
<img [src]="capture.previewUrl" alt="{{ doc.label }}" />
|
||||
@@ -1109,17 +1140,17 @@
|
||||
<div class="loi-cic-subsection">
|
||||
<div class="loi-cic-subsection-title">Co-Borrower (raises household eligibility)</div>
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [(ngModel)]="coBorrowerCapture.wanted" />
|
||||
<input type="checkbox" [(ngModel)]="a.coBorrowerWanted" />
|
||||
Add a Co-Borrower to increase eligibility
|
||||
</label>
|
||||
@if (coBorrowerCapture.wanted) {
|
||||
@if (a.coBorrowerWanted) {
|
||||
<label class="inv-form-field">
|
||||
<span>Co-Borrower Name</span>
|
||||
<input type="text" [(ngModel)]="a.coBorrowerName" placeholder="Full name" />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Relationship with Applicant</span>
|
||||
<select [(ngModel)]="coBorrowerCapture.relationship">
|
||||
<select [(ngModel)]="a.coBorrowerRelationship">
|
||||
<option value="">Select</option>
|
||||
<option value="Father">Father</option>
|
||||
<option value="Mother">Mother</option>
|
||||
@@ -1132,7 +1163,7 @@
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Co-Borrower Monthly Income (₹)</span>
|
||||
<input type="number" [(ngModel)]="coBorrowerCapture.monthlyIncome" placeholder="e.g. 30000" />
|
||||
<input type="number" [(ngModel)]="a.coBorrowerMonthlyIncome" placeholder="e.g. 30000" />
|
||||
</label>
|
||||
<p class="pg-sub npa-report-note">Upload the co-borrower's last 3 months' salary slips:</p>
|
||||
<div class="loi-kyc-card-files">
|
||||
@@ -1150,12 +1181,12 @@
|
||||
(change)="onCoBorrowerSalarySlipFile($index, $event)"
|
||||
/>
|
||||
</label>
|
||||
@if (doc.file) {
|
||||
@if (doc.file || hasStoredDoc('COBORROWER:salarySlip:' + $index)) {
|
||||
<button
|
||||
type="button"
|
||||
class="loi-kyc-thumb"
|
||||
title="View document"
|
||||
(click)="viewNewSingleDoc(doc.file)"
|
||||
(click)="viewKycSlot(a, doc.file, 'COBORROWER:salarySlip:' + $index)"
|
||||
>
|
||||
@if (doc.previewUrl) {
|
||||
<img [src]="doc.previewUrl" alt="Salary Slip Month {{ $index + 1 }}" />
|
||||
@@ -1180,11 +1211,11 @@
|
||||
</div>
|
||||
<div class="loan-kv-field">
|
||||
<dt>Co-Borrower's Monthly Income</dt>
|
||||
<dd>₹{{ coBorrowerCapture.monthlyIncome | number: '1.0-0' }}</dd>
|
||||
<dd>₹{{ a.coBorrowerMonthlyIncome | number: '1.0-0' }}</dd>
|
||||
</div>
|
||||
<div class="loan-kv-field">
|
||||
<dt>Co-Borrower's EMI Capacity (40%)</dt>
|
||||
<dd>₹{{ coBorrowerEmiCapacity() | number: '1.0-0' }}</dd>
|
||||
<dd>₹{{ coBorrowerEmiCapacity(a) | number: '1.0-0' }}</dd>
|
||||
</div>
|
||||
<div class="loan-kv-field">
|
||||
<dt>Combined Household EMI Capacity</dt>
|
||||
@@ -1197,10 +1228,10 @@
|
||||
</dl>
|
||||
<p class="pg-sub npa-report-note loi-emi-formula">
|
||||
Combined Capacity = (40% × ₹{{ a.monthlyIncome | number: '1.0-0' }} applicant) + (40% × ₹{{
|
||||
coBorrowerCapture.monthlyIncome | number: '1.0-0'
|
||||
a.coBorrowerMonthlyIncome | number: '1.0-0'
|
||||
}}
|
||||
co-borrower) = ₹{{ applicantEmiCapacity(a) | number: '1.0-0' }} + ₹{{
|
||||
coBorrowerEmiCapacity() | number: '1.0-0'
|
||||
coBorrowerEmiCapacity(a) | number: '1.0-0'
|
||||
}}
|
||||
= ₹{{ combinedEmiCapacity(a) | number: '1.0-0' }}. Estimated EMI ₹{{
|
||||
emiAmountFor(a) | number: '1.0-0'
|
||||
@@ -1220,7 +1251,7 @@
|
||||
@if (!affordabilityOk(a)) {
|
||||
<p class="loi-kyc-error">
|
||||
@if (!coBorrowerDetailsComplete(a)) {
|
||||
@if (!coBorrowerCapture.wanted) {
|
||||
@if (!a.coBorrowerWanted) {
|
||||
Check "Add a Co-Borrower" and complete their details to proceed.
|
||||
} @else {
|
||||
Add the co-borrower's name, relationship, monthly income and all 3 salary slips to proceed.
|
||||
@@ -1238,7 +1269,7 @@
|
||||
type="button"
|
||||
class="pg-btn pg-btn--primary"
|
||||
[disabled]="!allKycDone(a)"
|
||||
(click)="proceed(a, 'KYC/OVD documents verified and collected.')"
|
||||
(click)="completeKycStage(a)"
|
||||
>
|
||||
Proceed to CIC / Credit Bureau Check
|
||||
</button>
|
||||
@@ -1252,7 +1283,9 @@
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">CIC / Credit Bureau Check — {{ a.cicBureau }}</div>
|
||||
<div class="loan-action-card">
|
||||
@if (a.cibilScore === 0) {
|
||||
<!-- Consent and charge stay visible for the whole CIC stage, not just until the score
|
||||
is checked -- same layout either way, so what was collected (and by whom) is never
|
||||
a click away from disappearing once the score card below takes over. -->
|
||||
<div class="loi-cic-subsection">
|
||||
<div class="loi-cic-subsection-title">Customer Consent — Credit Bureau Check</div>
|
||||
<p class="pg-sub npa-report-note">
|
||||
@@ -1281,13 +1314,13 @@
|
||||
<input type="file" accept="image/*,application/pdf" hidden (change)="onCicConsentFile(a, $event)" />
|
||||
</label>
|
||||
</div>
|
||||
@if (cicConsentCapture.file) {
|
||||
@if (cicConsentCapture.file || hasStoredDoc('CIC:consent')) {
|
||||
<div class="loi-kyc-thumbs">
|
||||
<button
|
||||
type="button"
|
||||
class="loi-kyc-thumb"
|
||||
title="View document"
|
||||
(click)="viewNewSingleDoc(cicConsentCapture.file)"
|
||||
(click)="viewKycSlot(a, cicConsentCapture.file, 'CIC:consent')"
|
||||
>
|
||||
@if (cicConsentCapture.previewUrl) {
|
||||
<img [src]="cicConsentCapture.previewUrl" alt="Signed consent declaration" />
|
||||
@@ -1353,9 +1386,7 @@
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="copyCicPaymentLink(a)">
|
||||
Copy Link
|
||||
</button>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="emailCicPaymentLink(a)">
|
||||
Email
|
||||
</button>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="emailCicPaymentLink(a)">Email</button>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="whatsappCicPaymentLink(a)">
|
||||
WhatsApp
|
||||
</button>
|
||||
@@ -1384,6 +1415,7 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (a.cibilScore === 0) {
|
||||
<label class="inv-form-field">
|
||||
<span>Bureau</span>
|
||||
<select [(ngModel)]="a.cicBureau">
|
||||
@@ -1453,6 +1485,11 @@
|
||||
>
|
||||
📊 View Analysis
|
||||
</button>
|
||||
@if (hasStoredDoc('CIC:report')) {
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="viewKycSlot(a, null, 'CIC:report')">
|
||||
View file
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -1473,21 +1510,7 @@
|
||||
Push Field Inquiry to Agency
|
||||
</button>
|
||||
} @else if (a.cicReportUploaded && a.fiRequestPushed) {
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--primary"
|
||||
(click)="
|
||||
proceed(
|
||||
a,
|
||||
a.cicBureau +
|
||||
' report reviewed — score ' +
|
||||
a.cibilScore +
|
||||
' (' +
|
||||
cibilBandOf(a.cibilScore) +
|
||||
').'
|
||||
)
|
||||
"
|
||||
>
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="completeCicStage(a)">
|
||||
Proceed to Field Investigation
|
||||
</button>
|
||||
}
|
||||
@@ -1536,7 +1559,12 @@
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field">
|
||||
<span>Assign Field Officer</span>
|
||||
<input type="text" [(ngModel)]="fiOfficerNameInput[key]" placeholder="Officer name" />
|
||||
<select [(ngModel)]="fiOfficerNameInput[key]">
|
||||
<option value="">Select an officer</option>
|
||||
@for (staffMember of fiStaffOptions; track staffMember.id) {
|
||||
<option [value]="staffMember.displayName">{{ staffMember.displayName }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="loan-action-row">
|
||||
@@ -1642,7 +1670,12 @@
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field">
|
||||
<span>Re-Assign a New Field Officer</span>
|
||||
<input type="text" [(ngModel)]="fiOfficerNameInput[key]" placeholder="Officer name" />
|
||||
<select [(ngModel)]="fiOfficerNameInput[key]">
|
||||
<option value="">Select an officer</option>
|
||||
@for (staffMember of fiStaffOptions; track staffMember.id) {
|
||||
<option [value]="staffMember.displayName">{{ staffMember.displayName }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="loan-action-row">
|
||||
@@ -1735,6 +1768,13 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (a.agentReportUploaded && hasStoredDoc('FI:agentReport')) {
|
||||
<div class="loan-action-row">
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="viewKycSlot(a, null, 'FI:agentReport')">
|
||||
View file
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (!a.agentReportUploaded) {
|
||||
<label class="inv-form-field">
|
||||
<span>Agent Report Reference / Remark</span>
|
||||
@@ -2175,6 +2215,27 @@
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<!-- Already in the document store from an earlier session. -->
|
||||
@for (stored of storedDocsFor('LEGAL_DOC:' + doc.key); track stored.id) {
|
||||
<div class="loi-legal-doc-thumb-wrap">
|
||||
<button
|
||||
type="button"
|
||||
class="loi-kyc-thumb"
|
||||
title="{{ stored.fileName }}"
|
||||
(click)="viewStoredDoc(a, stored)"
|
||||
>
|
||||
<span class="loi-kyc-thumb-label">View file</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="loi-legal-doc-thumb-remove"
|
||||
title="Delete this stored file"
|
||||
(click)="removeStoredDoc(a, stored)"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (doc.submitted) {
|
||||
<span class="loan-badge loan-badge--verified">
|
||||
@@ -2341,10 +2402,30 @@
|
||||
</label>
|
||||
<p class="pg-sub npa-report-note">
|
||||
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.
|
||||
}
|
||||
</p>
|
||||
@if (a.fineractLoanError) {
|
||||
<p class="loi-kyc-error">{{ a.fineractLoanError }}</p>
|
||||
}
|
||||
<div class="loan-action-row">
|
||||
<button type="button" class="pg-btn pg-btn--success" (click)="disburseNow(a)">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--success"
|
||||
[disabled]="creatingRealLoan"
|
||||
(click)="disburseNow(a)"
|
||||
>
|
||||
@if (creatingRealLoan) {
|
||||
Creating Real Loan Account in Core Banking…
|
||||
} @else if (a.fineractLoanError) {
|
||||
Retry Disburse & Confirm EMI Activation
|
||||
} @else {
|
||||
Disburse Now & Confirm EMI Activation
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
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 = found && !this.isHeadOffice && found.branchCode !== this.ownBranchCode ? undefined : found;
|
||||
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,18 +2071,56 @@ 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] },
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------- Property Valuation (Field Investigation, secured products) --------------------
|
||||
@@ -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.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.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,
|
||||
this.runStageAction(
|
||||
this.originationService.decideApproval(a.applicationNo, field, {
|
||||
decision: 'APPROVED',
|
||||
approverName: this.currentUserName,
|
||||
approverRole,
|
||||
this.actionRemarks.trim() || 'Reviewed and approved.'
|
||||
remarks: this.actionRemarks.trim() || 'Reviewed and approved.'
|
||||
})
|
||||
);
|
||||
this.actionRemarks = '';
|
||||
}
|
||||
|
||||
rejectStage(a: LoanApplication, field: ApprovalField, approverRole: string): void {
|
||||
decideApproval(
|
||||
a,
|
||||
field,
|
||||
'REJECTED',
|
||||
this.currentUserName,
|
||||
this.runStageAction(
|
||||
this.originationService.decideApproval(a.applicationNo, field, {
|
||||
decision: 'REJECTED',
|
||||
approverName: this.currentUserName,
|
||||
approverRole,
|
||||
this.actionRemarks.trim() || 'Declined on review.'
|
||||
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) --------------------
|
||||
|
||||
+26
-7
@@ -38,24 +38,43 @@
|
||||
</dl>
|
||||
|
||||
<p class="fvd-photos-title">Site Photos ({{ data.check.photoCount }})</p>
|
||||
@if (data.photos.length > 0) {
|
||||
@if (data.photos.length > 0 || data.storedPhotos.length > 0) {
|
||||
<div class="fvd-photo-grid">
|
||||
@for (photo of data.photos; track $index) {
|
||||
<div class="fvd-photo">
|
||||
<button
|
||||
type="button"
|
||||
class="fvd-photo"
|
||||
[class.fvd-photo--zoomable]="!!photo.previewUrl"
|
||||
title="{{ photo.previewUrl ? 'Click to zoom' : photo.file.name }}"
|
||||
(click)="openPhotoZoom(photo.previewUrl)"
|
||||
>
|
||||
@if (photo.previewUrl) {
|
||||
<img [src]="photo.previewUrl" alt="Site photo {{ $index + 1 }}" />
|
||||
} @else {
|
||||
<span class="fvd-photo-label">{{ photo.file.name }}</span>
|
||||
}
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
@for (stored of data.storedPhotos; track stored.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="fvd-photo fvd-photo--stored"
|
||||
[class.fvd-photo--zoomable]="!!stored.previewUrl"
|
||||
title="{{ stored.previewUrl ? 'Click to zoom' : stored.fileName }}"
|
||||
(click)="stored.previewUrl ? openPhotoZoom(stored.previewUrl) : data.onViewStored(stored)"
|
||||
>
|
||||
@if (stored.previewUrl) {
|
||||
<img [src]="stored.previewUrl" alt="{{ stored.fileName }}" />
|
||||
} @else {
|
||||
<span class="fvd-photo-label">View file</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="fvd-no-photos">
|
||||
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.
|
||||
</p>
|
||||
<p class="fvd-no-photos">No photos on file for this visit.</p>
|
||||
}
|
||||
<div #lightboxRoot class="fvd-lightbox-root"></div>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button type="button" mat-button mat-dialog-close>Close</button>
|
||||
|
||||
+24
@@ -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;
|
||||
|
||||
+80
-6
@@ -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<FieldVisitDetailsDialogData>(MAT_DIALOG_DATA);
|
||||
|
||||
@ViewChild('lightboxRoot', { static: true }) lightboxRoot: ElementRef<HTMLElement>;
|
||||
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: `<div class="lg-caption"><h4>Site photo ${i + 1}</h4></div>`
|
||||
}));
|
||||
const stored = this.data.storedPhotos
|
||||
.filter((p) => !!p.previewUrl)
|
||||
.map((p) => ({
|
||||
src: p.previewUrl as string,
|
||||
subHtml: `<div class="lg-caption"><h4>${p.fileName || 'Site photo'}</h4></div>`
|
||||
}));
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RealLoanCreationResult> {
|
||||
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)
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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<LoanApplication[]> {
|
||||
return this.http.get<LoanApplication[]>(this.baseUrl);
|
||||
}
|
||||
|
||||
get(applicationNo: string): Observable<LoanApplication> {
|
||||
return this.http.get<LoanApplication>(`${this.baseUrl}/${encodeURIComponent(applicationNo)}`);
|
||||
}
|
||||
|
||||
create(application: LoanApplication): Observable<LoanApplication> {
|
||||
return this.http.post<LoanApplication>(this.baseUrl, application);
|
||||
}
|
||||
|
||||
save(application: LoanApplication): Observable<LoanApplication> {
|
||||
return this.http.put<LoanApplication>(
|
||||
`${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<LoanApplication> {
|
||||
return this.http.post<LoanApplication>(`${this.baseUrl}/${encodeURIComponent(applicationNo)}/${path}`, body);
|
||||
}
|
||||
|
||||
completeKyc(applicationNo: string, actionBy: string, remarks?: string): Observable<LoanApplication> {
|
||||
return this.stage(applicationNo, 'kyc-complete', { actionBy, remarks });
|
||||
}
|
||||
|
||||
completeCicCheck(applicationNo: string, actionBy: string, remarks?: string): Observable<LoanApplication> {
|
||||
return this.stage(applicationNo, 'cic-complete', { actionBy, remarks });
|
||||
}
|
||||
|
||||
verifyFieldCheck(
|
||||
applicationNo: string,
|
||||
checkKey: string,
|
||||
verifiedBy: string,
|
||||
remarks?: string
|
||||
): Observable<LoanApplication> {
|
||||
return this.stage(applicationNo, `field-investigation/${checkKey}/verify`, { verifiedBy, remarks });
|
||||
}
|
||||
|
||||
rejectFieldCheck(
|
||||
applicationNo: string,
|
||||
checkKey: string,
|
||||
verifiedBy: string,
|
||||
remarks: string
|
||||
): Observable<LoanApplication> {
|
||||
return this.stage(applicationNo, `field-investigation/${checkKey}/reject`, { verifiedBy, remarks });
|
||||
}
|
||||
|
||||
completeFieldInvestigation(applicationNo: string, actionBy: string, remarks?: string): Observable<LoanApplication> {
|
||||
return this.stage(applicationNo, 'field-investigation/complete', { actionBy, remarks });
|
||||
}
|
||||
|
||||
clearPersonalDiscussion(applicationNo: string, actionBy: string, remarks?: string): Observable<LoanApplication> {
|
||||
return this.stage(applicationNo, 'personal-discussion/clear', { actionBy, remarks });
|
||||
}
|
||||
|
||||
flagDiscrepancy(
|
||||
applicationNo: string,
|
||||
actionBy: string,
|
||||
discrepancyType: string,
|
||||
remarks: string
|
||||
): Observable<LoanApplication> {
|
||||
return this.stage(applicationNo, 'personal-discussion/flag', { actionBy, discrepancyType, remarks });
|
||||
}
|
||||
|
||||
resolveDiscrepancy(applicationNo: string, actionBy: string, remarks?: string): Observable<LoanApplication> {
|
||||
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<LoanApplication> {
|
||||
return this.stage(applicationNo, `approvals/${field}`, body);
|
||||
}
|
||||
|
||||
markSigned(applicationNo: string, actionBy: string): Observable<LoanApplication> {
|
||||
return this.stage(applicationNo, 'signing', { actionBy });
|
||||
}
|
||||
|
||||
confirmChequeAch(applicationNo: string, actionBy: string): Observable<LoanApplication> {
|
||||
return this.stage(applicationNo, 'cheque-ach', { actionBy });
|
||||
}
|
||||
|
||||
disburse(
|
||||
applicationNo: string,
|
||||
actionBy: string,
|
||||
disbursementMode: string,
|
||||
fineractLoan?: { fineractLoanId: number; fineractLoanAccountNo: string }
|
||||
): Observable<LoanApplication> {
|
||||
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<any> {
|
||||
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<any[]> {
|
||||
return this.http.get<any[]>(this.documentsUrl(applicationId));
|
||||
}
|
||||
|
||||
downloadDocument(applicationId: number, documentId: number): Observable<Blob> {
|
||||
return this.http.get(`${this.documentsUrl(applicationId)}/${documentId}/attachment`, { responseType: 'blob' });
|
||||
}
|
||||
|
||||
deleteDocument(applicationId: number, documentId: number): Observable<any> {
|
||||
return this.http.delete(`${this.documentsUrl(applicationId)}/${documentId}`);
|
||||
}
|
||||
}
|
||||
@@ -348,7 +348,14 @@
|
||||
}
|
||||
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="saveNewApplication()">Submit Application</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--primary"
|
||||
[disabled]="savingNewApplication"
|
||||
(click)="saveNewApplication()"
|
||||
>
|
||||
{{ savingNewApplication ? 'Saving…' : 'Submit Application' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -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,8 +630,14 @@ export class LoanPipelineComponent implements OnInit {
|
||||
},
|
||||
seq
|
||||
);
|
||||
LOAN_APPLICATIONS.unshift(app);
|
||||
this.toast(`Application ${app.applicationNo} received for ${app.applicantName}.`);
|
||||
// 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: '',
|
||||
@@ -596,8 +654,16 @@ export class LoanPipelineComponent implements OnInit {
|
||||
this.existingClientSearchResults = [];
|
||||
this.router.navigate([
|
||||
'/loan-origination/application',
|
||||
app.applicationNo
|
||||
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 --------------------
|
||||
|
||||
Reference in New Issue
Block a user