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/.
|
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) {
|
@if (application; as a) {
|
||||||
<header class="pg-head">
|
<header class="pg-head">
|
||||||
<div>
|
<div>
|
||||||
@@ -21,6 +28,18 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="pg-toolbar">
|
<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>
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="goBack()">‹ Back to Pipeline</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -86,8 +105,8 @@
|
|||||||
<dt>Co-Borrower</dt>
|
<dt>Co-Borrower</dt>
|
||||||
<dd>
|
<dd>
|
||||||
{{ a.coBorrowerName }}
|
{{ a.coBorrowerName }}
|
||||||
@if (coBorrowerCapture.wanted && coBorrowerCapture.relationship) {
|
@if (a.coBorrowerWanted && a.coBorrowerRelationship) {
|
||||||
({{ coBorrowerCapture.relationship }})
|
({{ a.coBorrowerRelationship }})
|
||||||
}
|
}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,6 +187,16 @@
|
|||||||
<dt>EMI Activation Confirmation</dt>
|
<dt>EMI Activation Confirmation</dt>
|
||||||
<dd>{{ a.emiActivationConfirmed ? 'Confirmed on ' + a.emiActivationDate : 'Pending' }}</dd>
|
<dd>{{ a.emiActivationConfirmed ? 'Confirmed on ' + a.emiActivationDate : 'Pending' }}</dd>
|
||||||
</div>
|
</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>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -336,12 +365,14 @@
|
|||||||
(change)="onNewSalariedProofFile(a, $event)"
|
(change)="onNewSalariedProofFile(a, $event)"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@if (newKycCapture.incomeProof.salaried.file) {
|
@if (newKycCapture.incomeProof.salaried.file || hasStoredDoc('KYC:incomeProof:salaried')) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="loi-kyc-thumb"
|
class="loi-kyc-thumb"
|
||||||
title="View document"
|
title="View document"
|
||||||
(click)="viewNewSingleDoc(newKycCapture.incomeProof.salaried.file)"
|
(click)="
|
||||||
|
viewKycSlot(a, newKycCapture.incomeProof.salaried.file, 'KYC:incomeProof:salaried')
|
||||||
|
"
|
||||||
>
|
>
|
||||||
@if (newKycCapture.incomeProof.salaried.previewUrl) {
|
@if (newKycCapture.incomeProof.salaried.previewUrl) {
|
||||||
<img [src]="newKycCapture.incomeProof.salaried.previewUrl" alt="Salary proof" />
|
<img [src]="newKycCapture.incomeProof.salaried.previewUrl" alt="Salary proof" />
|
||||||
@@ -367,12 +398,12 @@
|
|||||||
(change)="onNewItrFile(a, $index, $event)"
|
(change)="onNewItrFile(a, $index, $event)"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@if (doc.file) {
|
@if (doc.file || hasStoredDoc('KYC:incomeProof:itr:' + $index)) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="loi-kyc-thumb"
|
class="loi-kyc-thumb"
|
||||||
title="View document"
|
title="View document"
|
||||||
(click)="viewNewSingleDoc(doc.file)"
|
(click)="viewKycSlot(a, doc.file, 'KYC:incomeProof:itr:' + $index)"
|
||||||
>
|
>
|
||||||
@if (doc.previewUrl) {
|
@if (doc.previewUrl) {
|
||||||
<img [src]="doc.previewUrl" alt="ITR Year {{ $index + 1 }}" />
|
<img [src]="doc.previewUrl" alt="ITR Year {{ $index + 1 }}" />
|
||||||
@@ -436,12 +467,12 @@
|
|||||||
(change)="onNewBankStatementFile(a, $event)"
|
(change)="onNewBankStatementFile(a, $event)"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@if (newKycCapture.bankStatement.file) {
|
@if (newKycCapture.bankStatement.file || hasStoredDoc('KYC:bankStatement')) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="loi-kyc-thumb"
|
class="loi-kyc-thumb"
|
||||||
title="View document"
|
title="View document"
|
||||||
(click)="viewNewSingleDoc(newKycCapture.bankStatement.file)"
|
(click)="viewKycSlot(a, newKycCapture.bankStatement.file, 'KYC:bankStatement')"
|
||||||
>
|
>
|
||||||
@if (newKycCapture.bankStatement.previewUrl) {
|
@if (newKycCapture.bankStatement.previewUrl) {
|
||||||
<img [src]="newKycCapture.bankStatement.previewUrl" alt="Bank statement" />
|
<img [src]="newKycCapture.bankStatement.previewUrl" alt="Bank statement" />
|
||||||
@@ -449,12 +480,12 @@
|
|||||||
<span class="loi-kyc-thumb-label">View file</span>
|
<span class="loi-kyc-thumb-label">View file</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
@if (isCsvFile(newKycCapture.bankStatement.file)) {
|
@if (canReopenBankStatementAnalysis()) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="loi-bsa-reopen"
|
class="loi-bsa-reopen"
|
||||||
title="Re-open the transaction analysis"
|
title="Re-open the transaction analysis"
|
||||||
(click)="reopenBankStatementAnalysis(newKycCapture.bankStatement.file)"
|
(click)="reopenBankStatementAnalysisFor(a)"
|
||||||
>
|
>
|
||||||
📊 Re-check Analysis
|
📊 Re-check Analysis
|
||||||
</button>
|
</button>
|
||||||
@@ -498,12 +529,12 @@
|
|||||||
(change)="onNewElectricityBillFile(a, $event)"
|
(change)="onNewElectricityBillFile(a, $event)"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@if (newKycCapture.electricityBill.file) {
|
@if (newKycCapture.electricityBill.file || hasStoredDoc('KYC:electricityBill')) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="loi-kyc-thumb"
|
class="loi-kyc-thumb"
|
||||||
title="View document"
|
title="View document"
|
||||||
(click)="viewNewSingleDoc(newKycCapture.electricityBill.file)"
|
(click)="viewKycSlot(a, newKycCapture.electricityBill.file, 'KYC:electricityBill')"
|
||||||
>
|
>
|
||||||
@if (newKycCapture.electricityBill.previewUrl) {
|
@if (newKycCapture.electricityBill.previewUrl) {
|
||||||
<img [src]="newKycCapture.electricityBill.previewUrl" alt="Electricity bill" />
|
<img [src]="newKycCapture.electricityBill.previewUrl" alt="Electricity bill" />
|
||||||
@@ -949,12 +980,12 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@if (productDocCapture[doc.key]; as capture) {
|
@if (productDocCapture[doc.key]; as capture) {
|
||||||
@if (capture.file) {
|
@if (capture.file || hasStoredDoc('PRODUCT_DOC:' + doc.key)) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="loi-kyc-thumb"
|
class="loi-kyc-thumb"
|
||||||
title="View document"
|
title="View document"
|
||||||
(click)="viewNewSingleDoc(capture.file)"
|
(click)="viewKycSlot(a, capture.file, 'PRODUCT_DOC:' + doc.key)"
|
||||||
>
|
>
|
||||||
@if (capture.previewUrl) {
|
@if (capture.previewUrl) {
|
||||||
<img [src]="capture.previewUrl" alt="{{ doc.label }}" />
|
<img [src]="capture.previewUrl" alt="{{ doc.label }}" />
|
||||||
@@ -1109,17 +1140,17 @@
|
|||||||
<div class="loi-cic-subsection">
|
<div class="loi-cic-subsection">
|
||||||
<div class="loi-cic-subsection-title">Co-Borrower (raises household eligibility)</div>
|
<div class="loi-cic-subsection-title">Co-Borrower (raises household eligibility)</div>
|
||||||
<label class="loan-checklist-item">
|
<label class="loan-checklist-item">
|
||||||
<input type="checkbox" [(ngModel)]="coBorrowerCapture.wanted" />
|
<input type="checkbox" [(ngModel)]="a.coBorrowerWanted" />
|
||||||
Add a Co-Borrower to increase eligibility
|
Add a Co-Borrower to increase eligibility
|
||||||
</label>
|
</label>
|
||||||
@if (coBorrowerCapture.wanted) {
|
@if (a.coBorrowerWanted) {
|
||||||
<label class="inv-form-field">
|
<label class="inv-form-field">
|
||||||
<span>Co-Borrower Name</span>
|
<span>Co-Borrower Name</span>
|
||||||
<input type="text" [(ngModel)]="a.coBorrowerName" placeholder="Full name" />
|
<input type="text" [(ngModel)]="a.coBorrowerName" placeholder="Full name" />
|
||||||
</label>
|
</label>
|
||||||
<label class="inv-form-field">
|
<label class="inv-form-field">
|
||||||
<span>Relationship with Applicant</span>
|
<span>Relationship with Applicant</span>
|
||||||
<select [(ngModel)]="coBorrowerCapture.relationship">
|
<select [(ngModel)]="a.coBorrowerRelationship">
|
||||||
<option value="">Select</option>
|
<option value="">Select</option>
|
||||||
<option value="Father">Father</option>
|
<option value="Father">Father</option>
|
||||||
<option value="Mother">Mother</option>
|
<option value="Mother">Mother</option>
|
||||||
@@ -1132,7 +1163,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<label class="inv-form-field">
|
<label class="inv-form-field">
|
||||||
<span>Co-Borrower Monthly Income (₹)</span>
|
<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>
|
</label>
|
||||||
<p class="pg-sub npa-report-note">Upload the co-borrower's last 3 months' salary slips:</p>
|
<p class="pg-sub npa-report-note">Upload the co-borrower's last 3 months' salary slips:</p>
|
||||||
<div class="loi-kyc-card-files">
|
<div class="loi-kyc-card-files">
|
||||||
@@ -1150,12 +1181,12 @@
|
|||||||
(change)="onCoBorrowerSalarySlipFile($index, $event)"
|
(change)="onCoBorrowerSalarySlipFile($index, $event)"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@if (doc.file) {
|
@if (doc.file || hasStoredDoc('COBORROWER:salarySlip:' + $index)) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="loi-kyc-thumb"
|
class="loi-kyc-thumb"
|
||||||
title="View document"
|
title="View document"
|
||||||
(click)="viewNewSingleDoc(doc.file)"
|
(click)="viewKycSlot(a, doc.file, 'COBORROWER:salarySlip:' + $index)"
|
||||||
>
|
>
|
||||||
@if (doc.previewUrl) {
|
@if (doc.previewUrl) {
|
||||||
<img [src]="doc.previewUrl" alt="Salary Slip Month {{ $index + 1 }}" />
|
<img [src]="doc.previewUrl" alt="Salary Slip Month {{ $index + 1 }}" />
|
||||||
@@ -1180,11 +1211,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="loan-kv-field">
|
<div class="loan-kv-field">
|
||||||
<dt>Co-Borrower's Monthly Income</dt>
|
<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>
|
||||||
<div class="loan-kv-field">
|
<div class="loan-kv-field">
|
||||||
<dt>Co-Borrower's EMI Capacity (40%)</dt>
|
<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>
|
||||||
<div class="loan-kv-field">
|
<div class="loan-kv-field">
|
||||||
<dt>Combined Household EMI Capacity</dt>
|
<dt>Combined Household EMI Capacity</dt>
|
||||||
@@ -1197,10 +1228,10 @@
|
|||||||
</dl>
|
</dl>
|
||||||
<p class="pg-sub npa-report-note loi-emi-formula">
|
<p class="pg-sub npa-report-note loi-emi-formula">
|
||||||
Combined Capacity = (40% × ₹{{ a.monthlyIncome | number: '1.0-0' }} applicant) + (40% × ₹{{
|
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' }} + ₹{{
|
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 ₹{{
|
= ₹{{ combinedEmiCapacity(a) | number: '1.0-0' }}. Estimated EMI ₹{{
|
||||||
emiAmountFor(a) | number: '1.0-0'
|
emiAmountFor(a) | number: '1.0-0'
|
||||||
@@ -1220,7 +1251,7 @@
|
|||||||
@if (!affordabilityOk(a)) {
|
@if (!affordabilityOk(a)) {
|
||||||
<p class="loi-kyc-error">
|
<p class="loi-kyc-error">
|
||||||
@if (!coBorrowerDetailsComplete(a)) {
|
@if (!coBorrowerDetailsComplete(a)) {
|
||||||
@if (!coBorrowerCapture.wanted) {
|
@if (!a.coBorrowerWanted) {
|
||||||
Check "Add a Co-Borrower" and complete their details to proceed.
|
Check "Add a Co-Borrower" and complete their details to proceed.
|
||||||
} @else {
|
} @else {
|
||||||
Add the co-borrower's name, relationship, monthly income and all 3 salary slips to proceed.
|
Add the co-borrower's name, relationship, monthly income and all 3 salary slips to proceed.
|
||||||
@@ -1238,7 +1269,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="pg-btn pg-btn--primary"
|
class="pg-btn pg-btn--primary"
|
||||||
[disabled]="!allKycDone(a)"
|
[disabled]="!allKycDone(a)"
|
||||||
(click)="proceed(a, 'KYC/OVD documents verified and collected.')"
|
(click)="completeKycStage(a)"
|
||||||
>
|
>
|
||||||
Proceed to CIC / Credit Bureau Check
|
Proceed to CIC / Credit Bureau Check
|
||||||
</button>
|
</button>
|
||||||
@@ -1252,7 +1283,9 @@
|
|||||||
<div class="pg-card">
|
<div class="pg-card">
|
||||||
<div class="pos-section-label">CIC / Credit Bureau Check — {{ a.cicBureau }}</div>
|
<div class="pos-section-label">CIC / Credit Bureau Check — {{ a.cicBureau }}</div>
|
||||||
<div class="loan-action-card">
|
<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">
|
||||||
<div class="loi-cic-subsection-title">Customer Consent — Credit Bureau Check</div>
|
<div class="loi-cic-subsection-title">Customer Consent — Credit Bureau Check</div>
|
||||||
<p class="pg-sub npa-report-note">
|
<p class="pg-sub npa-report-note">
|
||||||
@@ -1281,13 +1314,13 @@
|
|||||||
<input type="file" accept="image/*,application/pdf" hidden (change)="onCicConsentFile(a, $event)" />
|
<input type="file" accept="image/*,application/pdf" hidden (change)="onCicConsentFile(a, $event)" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@if (cicConsentCapture.file) {
|
@if (cicConsentCapture.file || hasStoredDoc('CIC:consent')) {
|
||||||
<div class="loi-kyc-thumbs">
|
<div class="loi-kyc-thumbs">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="loi-kyc-thumb"
|
class="loi-kyc-thumb"
|
||||||
title="View document"
|
title="View document"
|
||||||
(click)="viewNewSingleDoc(cicConsentCapture.file)"
|
(click)="viewKycSlot(a, cicConsentCapture.file, 'CIC:consent')"
|
||||||
>
|
>
|
||||||
@if (cicConsentCapture.previewUrl) {
|
@if (cicConsentCapture.previewUrl) {
|
||||||
<img [src]="cicConsentCapture.previewUrl" alt="Signed consent declaration" />
|
<img [src]="cicConsentCapture.previewUrl" alt="Signed consent declaration" />
|
||||||
@@ -1353,9 +1386,7 @@
|
|||||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="copyCicPaymentLink(a)">
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="copyCicPaymentLink(a)">
|
||||||
Copy Link
|
Copy Link
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="emailCicPaymentLink(a)">
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="emailCicPaymentLink(a)">Email</button>
|
||||||
Email
|
|
||||||
</button>
|
|
||||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="whatsappCicPaymentLink(a)">
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="whatsappCicPaymentLink(a)">
|
||||||
WhatsApp
|
WhatsApp
|
||||||
</button>
|
</button>
|
||||||
@@ -1384,6 +1415,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (a.cibilScore === 0) {
|
||||||
<label class="inv-form-field">
|
<label class="inv-form-field">
|
||||||
<span>Bureau</span>
|
<span>Bureau</span>
|
||||||
<select [(ngModel)]="a.cicBureau">
|
<select [(ngModel)]="a.cicBureau">
|
||||||
@@ -1453,6 +1485,11 @@
|
|||||||
>
|
>
|
||||||
📊 View Analysis
|
📊 View Analysis
|
||||||
</button>
|
</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>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -1473,21 +1510,7 @@
|
|||||||
Push Field Inquiry to Agency
|
Push Field Inquiry to Agency
|
||||||
</button>
|
</button>
|
||||||
} @else if (a.cicReportUploaded && a.fiRequestPushed) {
|
} @else if (a.cicReportUploaded && a.fiRequestPushed) {
|
||||||
<button
|
<button type="button" class="pg-btn pg-btn--primary" (click)="completeCicStage(a)">
|
||||||
type="button"
|
|
||||||
class="pg-btn pg-btn--primary"
|
|
||||||
(click)="
|
|
||||||
proceed(
|
|
||||||
a,
|
|
||||||
a.cicBureau +
|
|
||||||
' report reviewed — score ' +
|
|
||||||
a.cibilScore +
|
|
||||||
' (' +
|
|
||||||
cibilBandOf(a.cibilScore) +
|
|
||||||
').'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
Proceed to Field Investigation
|
Proceed to Field Investigation
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -1536,7 +1559,12 @@
|
|||||||
<div class="inv-form-grid">
|
<div class="inv-form-grid">
|
||||||
<label class="inv-form-field">
|
<label class="inv-form-field">
|
||||||
<span>Assign Field Officer</span>
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="loan-action-row">
|
<div class="loan-action-row">
|
||||||
@@ -1642,7 +1670,12 @@
|
|||||||
<div class="inv-form-grid">
|
<div class="inv-form-grid">
|
||||||
<label class="inv-form-field">
|
<label class="inv-form-field">
|
||||||
<span>Re-Assign a New Field Officer</span>
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="loan-action-row">
|
<div class="loan-action-row">
|
||||||
@@ -1735,6 +1768,13 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</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) {
|
@if (!a.agentReportUploaded) {
|
||||||
<label class="inv-form-field">
|
<label class="inv-form-field">
|
||||||
<span>Agent Report Reference / Remark</span>
|
<span>Agent Report Reference / Remark</span>
|
||||||
@@ -2175,6 +2215,27 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
@if (doc.submitted) {
|
@if (doc.submitted) {
|
||||||
<span class="loan-badge loan-badge--verified">
|
<span class="loan-badge loan-badge--verified">
|
||||||
@@ -2341,10 +2402,30 @@
|
|||||||
</label>
|
</label>
|
||||||
<p class="pg-sub npa-report-note">
|
<p class="pg-sub npa-report-note">
|
||||||
Disbursing also confirms EMI Activation for this account in the same step.
|
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>
|
</p>
|
||||||
|
@if (a.fineractLoanError) {
|
||||||
|
<p class="loi-kyc-error">{{ a.fineractLoanError }}</p>
|
||||||
|
}
|
||||||
<div class="loan-action-row">
|
<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
|
Disburse Now & Confirm EMI Activation
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,13 +11,17 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { Subscription, forkJoin } from 'rxjs';
|
import { Observable, Subscription, forkJoin } from 'rxjs';
|
||||||
import { catchError } from 'rxjs/operators';
|
import { catchError, map } from 'rxjs/operators';
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
import { jsPDF } from 'jspdf';
|
import { jsPDF } from 'jspdf';
|
||||||
import autoTable from 'jspdf-autotable';
|
import autoTable from 'jspdf-autotable';
|
||||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||||
|
import { Dates } from '../../core/utils/dates';
|
||||||
import { ClientsService } from '../../clients/clients.service';
|
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 { SharesService } from '../../shares/shares.service';
|
||||||
import { KycUploadHelperService } from '../../clients/shared/kyc-upload-helper.service';
|
import { KycUploadHelperService } from '../../clients/shared/kyc-upload-helper.service';
|
||||||
import { DocumentPreviewDialogComponent } from '../../clients/shared/document-preview-dialog/document-preview-dialog.component';
|
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';
|
} from './cic-report-analysis-dialog/cic-report-analysis.util';
|
||||||
import { parseCicPdfReport } from './cic-report-analysis-dialog/cic-pdf-parser.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 { FieldVisitDetailsDialogComponent } from './field-visit-details-dialog/field-visit-details-dialog.component';
|
||||||
|
import { createRealLoanAccount } from '../loan-fineract-bridge';
|
||||||
|
import { LoanOriginationService } from '../loan-origination.service';
|
||||||
import {
|
import {
|
||||||
ApprovalField,
|
ApprovalField,
|
||||||
BRANCHES,
|
BRANCHES,
|
||||||
@@ -44,7 +50,6 @@ import {
|
|||||||
DISCREPANCY_LABELS,
|
DISCREPANCY_LABELS,
|
||||||
EmiScheduleRow,
|
EmiScheduleRow,
|
||||||
LoanApplication,
|
LoanApplication,
|
||||||
LOAN_APPLICATIONS,
|
|
||||||
ORIGINATION_PRODUCTS,
|
ORIGINATION_PRODUCTS,
|
||||||
OriginationStage,
|
OriginationStage,
|
||||||
STAGE_LABELS,
|
STAGE_LABELS,
|
||||||
@@ -59,28 +64,20 @@ import {
|
|||||||
cibilBand,
|
cibilBand,
|
||||||
CIC_BUREAUS,
|
CIC_BUREAUS,
|
||||||
CIC_CHECK_CHARGE,
|
CIC_CHECK_CHARGE,
|
||||||
clearPersonalDiscussionAndProceed,
|
|
||||||
confirmChequeAch,
|
|
||||||
decideApproval,
|
|
||||||
disburseApplication,
|
|
||||||
FiCheckKey,
|
FiCheckKey,
|
||||||
FI_CHECK_LABELS,
|
FI_CHECK_LABELS,
|
||||||
flagDiscrepancy,
|
|
||||||
MANAGERS,
|
MANAGERS,
|
||||||
markCustomerSigned,
|
|
||||||
moveToStage,
|
moveToStage,
|
||||||
ProductDocumentEntry,
|
ProductDocumentEntry,
|
||||||
progressPct,
|
progressPct,
|
||||||
PROPERTY_TYPES,
|
PROPERTY_TYPES,
|
||||||
pushFieldInquiry,
|
pushFieldInquiry,
|
||||||
resolveDiscrepancyAndProceed,
|
|
||||||
sanctioningAuthority,
|
sanctioningAuthority,
|
||||||
scheduleMeeting,
|
scheduleMeeting,
|
||||||
setMonthlyIncome,
|
setMonthlyIncome,
|
||||||
stageBadgeClass,
|
stageBadgeClass,
|
||||||
stageIndex,
|
stageIndex,
|
||||||
proceedToNextStage,
|
proceedToNextStage,
|
||||||
recordVerification,
|
|
||||||
todayDDMMYYYY,
|
todayDDMMYYYY,
|
||||||
uploadAgentReport,
|
uploadAgentReport,
|
||||||
uploadCicReport
|
uploadCicReport
|
||||||
@@ -163,6 +160,16 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
|||||||
actionRemarks = '';
|
actionRemarks = '';
|
||||||
discrepancySelection: DiscrepancyType = 'NONE';
|
discrepancySelection: DiscrepancyType = 'NONE';
|
||||||
disbursementMode = 'Account Credit';
|
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
|
/** 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
|
* "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. */
|
* 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 auth = inject(AuthenticationService);
|
||||||
private readonly clientsService = inject(ClientsService);
|
private readonly clientsService = inject(ClientsService);
|
||||||
private readonly sharesService = inject(SharesService);
|
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 kycUploadHelper = inject(KycUploadHelperService);
|
||||||
|
private readonly originationService = inject(LoanOriginationService);
|
||||||
private readonly dialog = inject(MatDialog);
|
private readonly dialog = inject(MatDialog);
|
||||||
private sub?: Subscription;
|
private sub?: Subscription;
|
||||||
|
|
||||||
@@ -249,27 +261,20 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Once `coBorrowerRequired` fires (either the eligible-amount shortfall in setMonthlyIncome()
|
/** 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
|
* or the 40%-EMI rule in setIncomeNow()), the officer opts into actually adding one via
|
||||||
* checkbox -- relationship, their own monthly income and last-3-months' salary slips, all
|
* `a.coBorrowerWanted`. Their name, relationship and monthly income live directly on the
|
||||||
* local-only like the rest of this mock pipeline. Their income only counts toward affordability
|
* application (so they survive a refresh); this holds only the in-flight local File objects for
|
||||||
* once all of that is in, see combinedEmiCapacity()/affordabilityOk() below. */
|
* 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: {
|
coBorrowerCapture: {
|
||||||
wanted: boolean;
|
|
||||||
relationship: string;
|
|
||||||
monthlyIncome: number | null;
|
|
||||||
salarySlips: NewKycSingleDoc[];
|
salarySlips: NewKycSingleDoc[];
|
||||||
} = this.emptyCoBorrowerCapture();
|
} = this.emptyCoBorrowerCapture();
|
||||||
|
|
||||||
private emptyCoBorrowerCapture(): {
|
private emptyCoBorrowerCapture(): {
|
||||||
wanted: boolean;
|
|
||||||
relationship: string;
|
|
||||||
monthlyIncome: number | null;
|
|
||||||
salarySlips: NewKycSingleDoc[];
|
salarySlips: NewKycSingleDoc[];
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
wanted: false,
|
|
||||||
relationship: '',
|
|
||||||
monthlyIncome: null,
|
|
||||||
salarySlips: [
|
salarySlips: [
|
||||||
{ file: null, previewUrl: null },
|
{ file: null, previewUrl: null },
|
||||||
{ file: null, previewUrl: null },
|
{ file: null, previewUrl: null },
|
||||||
@@ -295,6 +300,10 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
|||||||
shopVerification: ''
|
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. */
|
/** Ditto for the rejection remark textarea. */
|
||||||
fiRejectRemarkInput: Record<FiCheckKey, string> = {
|
fiRejectRemarkInput: Record<FiCheckKey, string> = {
|
||||||
addressVerification: '',
|
addressVerification: '',
|
||||||
@@ -406,13 +415,42 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
|||||||
this.fiTickTimer = setInterval(() => {
|
this.fiTickTimer = setInterval(() => {
|
||||||
this.nowTick = Date.now();
|
this.nowTick = Date.now();
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
this.originationService.listStaff().subscribe({
|
||||||
|
next: (staff) => (this.fiStaffOptions = staff ?? []),
|
||||||
|
error: () => (this.fiStaffOptions = [])
|
||||||
|
});
|
||||||
this.sub = this.route.paramMap.subscribe((params) => {
|
this.sub = this.route.paramMap.subscribe((params) => {
|
||||||
const applicationNo = params.get('applicationNo');
|
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
|
// 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
|
// exactly like "not found" rather than a distinct "access denied", so it doesn't reveal
|
||||||
// that a differently-branched application exists at all.
|
// 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.actionRemarks = '';
|
||||||
this.discrepancySelection = 'NONE';
|
this.discrepancySelection = 'NONE';
|
||||||
this.monthlyIncomeInput = this.application?.monthlyIncome || null;
|
this.monthlyIncomeInput = this.application?.monthlyIncome || null;
|
||||||
@@ -468,7 +506,10 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
|||||||
this.loadRealClientAccounts();
|
this.loadRealClientAccounts();
|
||||||
this.loadRealKyc();
|
this.loadRealKyc();
|
||||||
this.loadRealAddresses();
|
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
|
/** 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[side] = cropped;
|
||||||
target[previewKey] = cropped.type.startsWith('image/') ? URL.createObjectURL(cropped) : null;
|
target[previewKey] = cropped.type.startsWith('image/') ? URL.createObjectURL(cropped) : null;
|
||||||
|
this.uploadCaptured(cropped, `KYC:${doc}:${side}`, `${doc === 'aadhaar' ? 'Aadhaar' : 'PAN'} — ${side}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
sendNewAadhaarOtp(): void {
|
sendNewAadhaarOtp(): void {
|
||||||
@@ -762,6 +804,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.setSingleDoc(this.newKycCapture.incomeProof.salaried, file);
|
this.setSingleDoc(this.newKycCapture.incomeProof.salaried, file);
|
||||||
|
this.uploadCaptured(file, 'KYC:incomeProof:salaried', 'Form 16 / 3 Month Salary Slip');
|
||||||
this.syncIncomeProofSubmitted(a);
|
this.syncIncomeProofSubmitted(a);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -771,6 +814,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.setSingleDoc(this.newKycCapture.incomeProof.itr[yearIndex], file);
|
this.setSingleDoc(this.newKycCapture.incomeProof.itr[yearIndex], file);
|
||||||
|
this.uploadCaptured(file, `KYC:incomeProof:itr:${yearIndex}`, `ITR — Year ${yearIndex + 1}`);
|
||||||
this.syncIncomeProofSubmitted(a);
|
this.syncIncomeProofSubmitted(a);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -804,6 +848,8 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
this.setSingleDoc(this.newKycCapture.bankStatement, file);
|
this.setSingleDoc(this.newKycCapture.bankStatement, file);
|
||||||
a.bankStatementSubmitted = true;
|
a.bankStatementSubmitted = true;
|
||||||
|
this.uploadCaptured(file, 'KYC:bankStatement', 'Bank Statement');
|
||||||
|
this.persist();
|
||||||
if (this.isCsvFile(file)) {
|
if (this.isCsvFile(file)) {
|
||||||
await this.analyzeBankStatement(file);
|
await this.analyzeBankStatement(file);
|
||||||
}
|
}
|
||||||
@@ -823,6 +869,37 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
|||||||
void this.analyzeBankStatement(file);
|
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
|
/** 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. */
|
* opens straight into the transaction analysis instead of just filing it away unread. */
|
||||||
private async analyzeBankStatement(file: File): Promise<void> {
|
private async analyzeBankStatement(file: File): Promise<void> {
|
||||||
@@ -861,6 +938,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.setSingleDoc(this.newKycCapture.electricityBill, file);
|
this.setSingleDoc(this.newKycCapture.electricityBill, file);
|
||||||
|
this.uploadCaptured(file, 'KYC:electricityBill', 'Electricity Bill');
|
||||||
this.syncElectricityBillSubmitted(a);
|
this.syncElectricityBillSubmitted(a);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -879,6 +957,8 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
this.setSingleDoc(this.productDocCapture[doc.key], file);
|
this.setSingleDoc(this.productDocCapture[doc.key], file);
|
||||||
doc.submitted = true;
|
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
|
/** 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 });
|
a.productDocuments.push({ key, label, custom: true, submitted: false });
|
||||||
this.productDocCapture[key] = { file: null, previewUrl: null };
|
this.productDocCapture[key] = { file: null, previewUrl: null };
|
||||||
this.newCustomDocLabel = '';
|
this.newCustomDocLabel = '';
|
||||||
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Only officer-added documents can be removed -- a product's own required list stays fixed,
|
/** 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);
|
URL.revokeObjectURL(capture.previewUrl);
|
||||||
}
|
}
|
||||||
delete this.productDocCapture[doc.key];
|
delete this.productDocCapture[doc.key];
|
||||||
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- Legal documents (Legal Approval) --------------------
|
// -------------------- Legal documents (Legal Approval) --------------------
|
||||||
@@ -928,12 +1010,26 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
|||||||
this.legalDocCapture[doc.key] = [];
|
this.legalDocCapture[doc.key] = [];
|
||||||
}
|
}
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
this.legalDocCapture[doc.key].push({
|
const captured: NewKycSingleDoc = {
|
||||||
file,
|
file,
|
||||||
previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : null
|
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;
|
doc.submitted = true;
|
||||||
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
removeLegalDocFile(doc: ProductDocumentEntry, index: number): void {
|
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 });
|
a.legalDocuments.push({ key, label, custom: true, submitted: false });
|
||||||
this.legalDocCapture[key] = [];
|
this.legalDocCapture[key] = [];
|
||||||
this.newLegalDocLabel = '';
|
this.newLegalDocLabel = '';
|
||||||
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
removeCustomLegalDoc(a: LoanApplication, doc: ProductDocumentEntry): void {
|
removeCustomLegalDoc(a: LoanApplication, doc: ProductDocumentEntry): void {
|
||||||
@@ -992,6 +1089,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
delete this.legalDocCapture[doc.key];
|
delete this.legalDocCapture[doc.key];
|
||||||
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
onCoOwnerToggle(a: LoanApplication): void {
|
onCoOwnerToggle(a: LoanApplication): void {
|
||||||
@@ -1005,6 +1103,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.setSingleDoc(this.coBorrowerCapture.salarySlips[index], file);
|
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,
|
/** 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 {
|
coBorrowerDetailsComplete(a: LoanApplication): boolean {
|
||||||
const hasName = !!a.coBorrowerName && a.coBorrowerName !== '—' && a.coBorrowerName !== 'To be added';
|
const hasName = !!a.coBorrowerName && a.coBorrowerName !== '—' && a.coBorrowerName !== 'To be added';
|
||||||
return (
|
return (
|
||||||
this.coBorrowerCapture.wanted &&
|
a.coBorrowerWanted &&
|
||||||
hasName &&
|
hasName &&
|
||||||
!!this.coBorrowerCapture.relationship &&
|
!!a.coBorrowerRelationship &&
|
||||||
!!this.coBorrowerCapture.monthlyIncome &&
|
!!a.coBorrowerMonthlyIncome &&
|
||||||
this.coBorrowerCapture.monthlyIncome > 0 &&
|
a.coBorrowerMonthlyIncome > 0 &&
|
||||||
this.coBorrowerCapture.salarySlips.every((doc) => !!doc.file)
|
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;
|
return a.monthlyIncome * 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
coBorrowerEmiCapacity(): number {
|
coBorrowerEmiCapacity(a: LoanApplication): number {
|
||||||
return (this.coBorrowerCapture.monthlyIncome || 0) * 0.4;
|
return (a.coBorrowerMonthlyIncome || 0) * 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The 40% affordability rule applied to the household rather than the applicant alone: 40% of
|
/** 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
|
* 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. */
|
* complete -- income and salary slips, not just a number typed in), added together. */
|
||||||
combinedEmiCapacity(a: LoanApplication): number {
|
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;
|
return this.applicantEmiCapacity(a) + coBorrowerCapacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1059,7 +1160,20 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
|||||||
maxWidth: '95vw'
|
maxWidth: '95vw'
|
||||||
})
|
})
|
||||||
.afterClosed()
|
.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
|
/** 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 {
|
ngOnDestroy(): void {
|
||||||
|
this.flushPendingInlineEdit();
|
||||||
this.sub?.unsubscribe();
|
this.sub?.unsubscribe();
|
||||||
this.revokeNewKycPreviews(this.newKycCapture);
|
this.revokeNewKycPreviews(this.newKycCapture);
|
||||||
this.revokeProductDocPreviews();
|
this.revokeProductDocPreviews();
|
||||||
@@ -1218,6 +1506,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
checkCreditScore(a, this.currentUserName);
|
checkCreditScore(a, this.currentUserName);
|
||||||
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Uploading the customer's signed copy of the consent declaration is what actually unlocks
|
/** 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);
|
this.setSingleDoc(this.cicConsentCapture, cropped);
|
||||||
a.cicConsentSigned = true;
|
a.cicConsentSigned = true;
|
||||||
|
this.uploadCaptured(cropped, 'CIC:consent', 'Signed Consent Declaration');
|
||||||
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- CIC check charge --------------------
|
// -------------------- CIC check charge --------------------
|
||||||
@@ -1251,6 +1542,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
|||||||
a.cicChargeAccountLabel = `${account.accountNo} — ${account.kind} (${account.productName})`;
|
a.cicChargeAccountLabel = `${account.accountNo} — ${account.kind} (${account.productName})`;
|
||||||
a.cicChargeCollected = true;
|
a.cicChargeCollected = true;
|
||||||
this.toast(`Rs. ${this.cicCheckCharge} CIC check charge marked for debit from ${account.accountNo}.`);
|
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
|
/** 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 {
|
generateCicPaymentLink(a: LoanApplication): void {
|
||||||
a.cicChargeMode = 'PAYMENT_LINK';
|
a.cicChargeMode = 'PAYMENT_LINK';
|
||||||
a.cicPaymentLinkUrl = `https://pay.cbsnexora.local/cic-charge/${a.applicationNo}?amount=${this.cicCheckCharge}`;
|
a.cicPaymentLinkUrl = `https://pay.cbsnexora.local/cic-charge/${a.applicationNo}?amount=${this.cicCheckCharge}`;
|
||||||
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
copyCicPaymentLink(a: LoanApplication): void {
|
copyCicPaymentLink(a: LoanApplication): void {
|
||||||
@@ -1303,6 +1596,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
|||||||
a.cicPaymentReference = this.cicPaymentRefInput.trim();
|
a.cicPaymentReference = this.cicPaymentRefInput.trim();
|
||||||
a.cicChargeCollected = true;
|
a.cicChargeCollected = true;
|
||||||
this.cicPaymentRefInput = '';
|
this.cicPaymentRefInput = '';
|
||||||
|
this.persist();
|
||||||
this.toast('Payment reference recorded — CIC check charge marked as collected.');
|
this.toast('Payment reference recorded — CIC check charge marked as collected.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1480,8 +1774,37 @@ I confirm that:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- Generic stage-advance actions --------------------
|
// -------------------- 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 {
|
proceed(a: LoanApplication, remarks: string): void {
|
||||||
proceedToNextStage(a, this.currentUserName, remarks);
|
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
|
/** Any stage the application has ever reached is clickable -- whether it's currently behind or
|
||||||
@@ -1502,6 +1825,7 @@ I confirm that:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
moveToStage(a, stage, this.currentUserName);
|
moveToStage(a, stage, this.currentUserName);
|
||||||
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- KYC & Documents --------------------
|
// -------------------- KYC & Documents --------------------
|
||||||
@@ -1606,6 +1930,7 @@ I confirm that:
|
|||||||
a.coBorrowerName = 'To be added';
|
a.coBorrowerName = 'To be added';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
allKycDone(a: LoanApplication): boolean {
|
allKycDone(a: LoanApplication): boolean {
|
||||||
@@ -1661,6 +1986,7 @@ I confirm that:
|
|||||||
URL.revokeObjectURL(doc.previewUrl);
|
URL.revokeObjectURL(doc.previewUrl);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
async onFiPhotoFile(a: LoanApplication, key: FiCheckKey, event: Event): Promise<void> {
|
async onFiPhotoFile(a: LoanApplication, key: FiCheckKey, event: Event): Promise<void> {
|
||||||
@@ -1672,8 +1998,10 @@ I confirm that:
|
|||||||
file,
|
file,
|
||||||
previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : null
|
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;
|
a[key].photoCount = this.fiPhotoCapture[key].length;
|
||||||
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
removeFiPhoto(a: LoanApplication, key: FiCheckKey, index: number): void {
|
removeFiPhoto(a: LoanApplication, key: FiCheckKey, index: number): void {
|
||||||
@@ -1682,6 +2010,7 @@ I confirm that:
|
|||||||
URL.revokeObjectURL(removed.previewUrl);
|
URL.revokeObjectURL(removed.previewUrl);
|
||||||
}
|
}
|
||||||
a[key].photoCount = this.fiPhotoCapture[key].length;
|
a[key].photoCount = this.fiPhotoCapture[key].length;
|
||||||
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A visit can only be marked Verified once the officer has actually captured something --
|
/** 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)) {
|
if (!this.fiCanVerify(a, key)) {
|
||||||
return;
|
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 {
|
rejectFieldVisit(a: LoanApplication, key: FiCheckKey): void {
|
||||||
@@ -1704,8 +2042,8 @@ I confirm that:
|
|||||||
if (!remark) {
|
if (!remark) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
recordVerification(a, key, 'FAILED', a[key].officerAssigned, remark);
|
|
||||||
this.fiRejectRemarkInput[key] = '';
|
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
|
/** 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 {
|
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
|
/** 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
|
* 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. */
|
* all into the card and breaking the grid layout. */
|
||||||
openFiVisitDetails(a: LoanApplication, key: FiCheckKey): void {
|
openFiVisitDetails(a: LoanApplication, key: FiCheckKey): void {
|
||||||
this.dialog.open(FieldVisitDetailsDialogComponent, {
|
const stored = this.storedDocsFor(`FI:${key}`);
|
||||||
data: { label: this.fiCheckLabels[key], check: a[key], photos: this.fiPhotoCapture[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',
|
width: '520px',
|
||||||
maxWidth: '95vw'
|
maxWidth: '95vw'
|
||||||
});
|
});
|
||||||
|
ref.afterClosed().subscribe(() => {
|
||||||
|
storedPhotos.forEach((p: { previewUrl: string | null }) => {
|
||||||
|
if (p.previewUrl) {
|
||||||
|
URL.revokeObjectURL(p.previewUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- Property Valuation (Field Investigation, secured products) --------------------
|
// -------------------- Property Valuation (Field Investigation, secured products) --------------------
|
||||||
@@ -1806,6 +2182,7 @@ I confirm that:
|
|||||||
a.meetingWith.trim(),
|
a.meetingWith.trim(),
|
||||||
this.currentUserName
|
this.currentUserName
|
||||||
);
|
);
|
||||||
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- Application Summary Report (briefing doc for the meeting) --------------------
|
// -------------------- Application Summary Report (briefing doc for the meeting) --------------------
|
||||||
@@ -2060,35 +2437,38 @@ I confirm that:
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearPersonalDiscussion(a: LoanApplication): void {
|
clearPersonalDiscussion(a: LoanApplication): void {
|
||||||
clearPersonalDiscussionAndProceed(
|
this.runStageAction(
|
||||||
a,
|
this.originationService.clearPersonalDiscussion(
|
||||||
|
a.applicationNo,
|
||||||
this.currentUserName,
|
this.currentUserName,
|
||||||
this.actionRemarks.trim() || 'No discrepancy found — cleared to proceed to Loan Committee.'
|
this.actionRemarks.trim() || 'No discrepancy found — cleared to proceed to Loan Committee.'
|
||||||
|
)
|
||||||
);
|
);
|
||||||
this.actionRemarks = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
flagPersonalDiscussion(a: LoanApplication): void {
|
flagPersonalDiscussion(a: LoanApplication): void {
|
||||||
const notes =
|
const notes =
|
||||||
this.actionRemarks.trim() ||
|
this.actionRemarks.trim() ||
|
||||||
`${this.discrepancyLabels[this.discrepancySelection]} flagged during personal discussion — held pending resolution.`;
|
`${this.discrepancyLabels[this.discrepancySelection]} flagged during personal discussion — held pending resolution.`;
|
||||||
flagDiscrepancy(a, this.discrepancySelection, this.currentUserName, notes);
|
const type = this.discrepancySelection;
|
||||||
this.actionRemarks = '';
|
|
||||||
this.discrepancySelection = 'NONE';
|
this.discrepancySelection = 'NONE';
|
||||||
|
this.runStageAction(this.originationService.flagDiscrepancy(a.applicationNo, this.currentUserName, type, notes));
|
||||||
}
|
}
|
||||||
|
|
||||||
resolveDiscrepancy(a: LoanApplication): void {
|
resolveDiscrepancy(a: LoanApplication): void {
|
||||||
resolveDiscrepancyAndProceed(
|
this.runStageAction(
|
||||||
a,
|
this.originationService.resolveDiscrepancy(
|
||||||
|
a.applicationNo,
|
||||||
this.currentUserName,
|
this.currentUserName,
|
||||||
this.actionRemarks.trim() || 'Discrepancy resolved on further review — cleared to proceed.'
|
this.actionRemarks.trim() || 'Discrepancy resolved on further review — cleared to proceed.'
|
||||||
|
)
|
||||||
);
|
);
|
||||||
this.actionRemarks = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- Share Membership --------------------
|
// -------------------- Share Membership --------------------
|
||||||
allotMembership(a: LoanApplication): void {
|
allotMembership(a: LoanApplication): void {
|
||||||
allotShareMembership(a, this.currentUserName);
|
allotShareMembership(a, this.currentUserName);
|
||||||
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- Approvals (Credit Committee / Risk / Legal / Post-Check) --------------------
|
// -------------------- Approvals (Credit Committee / Risk / Legal / Post-Check) --------------------
|
||||||
@@ -2103,45 +2483,49 @@ I confirm that:
|
|||||||
if (!this.committeeCanApprove(a)) {
|
if (!this.committeeCanApprove(a)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const remarks = `${this.actionRemarks.trim() || 'Reviewed and approved.'} (Resolution No. ${a.committeeResolutionNo.trim()} dated ${a.committeeResolutionDate.trim()})`;
|
this.runStageAction(
|
||||||
decideApproval(a, 'creditCommitteeApproval', 'APPROVED', this.currentUserName, 'Sanctioning Authority', remarks);
|
this.originationService.decideApproval(a.applicationNo, 'creditCommitteeApproval', {
|
||||||
this.actionRemarks = '';
|
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 {
|
approveStage(a: LoanApplication, field: ApprovalField, approverRole: string): void {
|
||||||
decideApproval(
|
this.runStageAction(
|
||||||
a,
|
this.originationService.decideApproval(a.applicationNo, field, {
|
||||||
field,
|
decision: 'APPROVED',
|
||||||
'APPROVED',
|
approverName: this.currentUserName,
|
||||||
this.currentUserName,
|
|
||||||
approverRole,
|
approverRole,
|
||||||
this.actionRemarks.trim() || 'Reviewed and approved.'
|
remarks: this.actionRemarks.trim() || 'Reviewed and approved.'
|
||||||
|
})
|
||||||
);
|
);
|
||||||
this.actionRemarks = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rejectStage(a: LoanApplication, field: ApprovalField, approverRole: string): void {
|
rejectStage(a: LoanApplication, field: ApprovalField, approverRole: string): void {
|
||||||
decideApproval(
|
this.runStageAction(
|
||||||
a,
|
this.originationService.decideApproval(a.applicationNo, field, {
|
||||||
field,
|
decision: 'REJECTED',
|
||||||
'REJECTED',
|
approverName: this.currentUserName,
|
||||||
this.currentUserName,
|
|
||||||
approverRole,
|
approverRole,
|
||||||
this.actionRemarks.trim() || 'Declined on review.'
|
remarks: this.actionRemarks.trim() || 'Declined on review.'
|
||||||
|
})
|
||||||
);
|
);
|
||||||
this.actionRemarks = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
exceptionApprove(a: LoanApplication): void {
|
exceptionApprove(a: LoanApplication): void {
|
||||||
decideApproval(
|
this.runStageAction(
|
||||||
a,
|
this.originationService.decideApproval(a.applicationNo, 'postCheckApproval', {
|
||||||
'postCheckApproval',
|
decision: 'EXCEPTION_APPROVED',
|
||||||
'EXCEPTION_APPROVED',
|
approverName: this.currentUserName,
|
||||||
this.currentUserName,
|
approverRole: 'Post-Disbursement Check',
|
||||||
'Post-Disbursement Check',
|
remarks: this.actionRemarks.trim() || 'Approved on exception — minor gap noted and accepted.'
|
||||||
this.actionRemarks.trim() || 'Approved on exception — minor gap noted and accepted.'
|
})
|
||||||
);
|
);
|
||||||
this.actionRemarks = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
legalChecklistDone(a: LoanApplication): boolean {
|
legalChecklistDone(a: LoanApplication): boolean {
|
||||||
@@ -2227,6 +2611,7 @@ I confirm that:
|
|||||||
this.guarantorShareAccounts = [];
|
this.guarantorShareAccounts = [];
|
||||||
this.guarantorSearchText = '';
|
this.guarantorSearchText = '';
|
||||||
this.guarantorSearchResults = [];
|
this.guarantorSearchResults = [];
|
||||||
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Same `/clients/{id}/accounts` call the applicant's own Share Member check uses -- a
|
/** 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 ?? [];
|
this.guarantorShareAccounts = data?.shareAccounts ?? [];
|
||||||
a.guarantorHasShareAccount = this.guarantorShareAccounts.length > 0;
|
a.guarantorHasShareAccount = this.guarantorShareAccounts.length > 0;
|
||||||
this.loadingGuarantorShareCheck = false;
|
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);
|
uploadCicReport(a, file.name, this.currentUserName);
|
||||||
input.value = '';
|
input.value = '';
|
||||||
|
this.uploadCaptured(file, 'CIC:report', `${a.cicBureau} Credit Report`);
|
||||||
|
this.persist();
|
||||||
this.openCicReportAnalysis(a);
|
this.openCicReportAnalysis(a);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2306,6 +2696,7 @@ I confirm that:
|
|||||||
|
|
||||||
pushFieldInquiryNow(a: LoanApplication): void {
|
pushFieldInquiryNow(a: LoanApplication): void {
|
||||||
pushFieldInquiry(a, 'Field Verification Team', this.currentUserName);
|
pushFieldInquiry(a, 'Field Verification Team', this.currentUserName);
|
||||||
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Free-text reference/summary the officer types before attaching the Agent Report file --
|
/** Free-text reference/summary the officer types before attaching the Agent Report file --
|
||||||
@@ -2319,21 +2710,54 @@ I confirm that:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
uploadAgentReport(a, file.name, this.agentReportRemarkInput.trim(), this.currentUserName);
|
uploadAgentReport(a, file.name, this.agentReportRemarkInput.trim(), this.currentUserName);
|
||||||
|
this.uploadCaptured(file, 'FI:agentReport', 'Field Investigation Agent Report');
|
||||||
this.agentReportRemarkInput = '';
|
this.agentReportRemarkInput = '';
|
||||||
input.value = '';
|
input.value = '';
|
||||||
|
this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- Signing / Cheque-ACH / Disbursement --------------------
|
// -------------------- Signing / Cheque-ACH / Disbursement --------------------
|
||||||
markSigned(a: LoanApplication): void {
|
markSigned(a: LoanApplication): void {
|
||||||
markCustomerSigned(a, a.applicantName);
|
this.runStageAction(this.originationService.markSigned(a.applicationNo, a.applicantName));
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmChequeAndProceed(a: LoanApplication): void {
|
confirmChequeAndProceed(a: LoanApplication): void {
|
||||||
confirmChequeAch(a, this.currentUserName);
|
this.runStageAction(this.originationService.confirmChequeAch(a.applicationNo, this.currentUserName));
|
||||||
}
|
}
|
||||||
|
|
||||||
disburseNow(a: LoanApplication): void {
|
/** For an Existing-Customer application, this is the one point where the pipeline stops being a
|
||||||
disburseApplication(a, this.currentUserName, this.disbursementMode);
|
* 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) --------------------
|
// -------------------- EMI Repayment Schedule (once disbursed) --------------------
|
||||||
|
|||||||
+26
-7
@@ -38,24 +38,43 @@
|
|||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<p class="fvd-photos-title">Site Photos ({{ data.check.photoCount }})</p>
|
<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">
|
<div class="fvd-photo-grid">
|
||||||
@for (photo of data.photos; track $index) {
|
@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) {
|
@if (photo.previewUrl) {
|
||||||
<img [src]="photo.previewUrl" alt="Site photo {{ $index + 1 }}" />
|
<img [src]="photo.previewUrl" alt="Site photo {{ $index + 1 }}" />
|
||||||
} @else {
|
} @else {
|
||||||
<span class="fvd-photo-label">{{ photo.file.name }}</span>
|
<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>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<p class="fvd-no-photos">
|
<p class="fvd-no-photos">No photos on file for this visit.</p>
|
||||||
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>
|
|
||||||
}
|
}
|
||||||
|
<div #lightboxRoot class="fvd-lightbox-root"></div>
|
||||||
</mat-dialog-content>
|
</mat-dialog-content>
|
||||||
<mat-dialog-actions align="end">
|
<mat-dialog-actions align="end">
|
||||||
<button type="button" mat-button mat-dialog-close>Close</button>
|
<button type="button" mat-button mat-dialog-close>Close</button>
|
||||||
|
|||||||
+24
@@ -90,6 +90,10 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font: inherit;
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 100%;
|
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 {
|
.fvd-photo-label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
|
|||||||
+80
-6
@@ -6,10 +6,15 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* 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 { CommonModule } from '@angular/common';
|
||||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
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';
|
import { VerificationCheck } from '../../loan-origination-data';
|
||||||
|
|
||||||
export interface FieldVisitPhoto {
|
export interface FieldVisitPhoto {
|
||||||
@@ -21,15 +26,18 @@ export interface FieldVisitDetailsDialogData {
|
|||||||
label: string;
|
label: string;
|
||||||
check: VerificationCheck;
|
check: VerificationCheck;
|
||||||
photos: FieldVisitPhoto[];
|
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,
|
* 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
|
* 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
|
* short summary once the visit is Verified/Failed.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-field-visit-details-dialog',
|
selector: 'mifosx-field-visit-details-dialog',
|
||||||
@@ -42,9 +50,16 @@ export interface FieldVisitDetailsDialogData {
|
|||||||
templateUrl: './field-visit-details-dialog.component.html',
|
templateUrl: './field-visit-details-dialog.component.html',
|
||||||
styleUrls: ['./field-visit-details-dialog.component.scss']
|
styleUrls: ['./field-visit-details-dialog.component.scss']
|
||||||
})
|
})
|
||||||
export class FieldVisitDetailsDialogComponent {
|
export class FieldVisitDetailsDialogComponent implements OnDestroy {
|
||||||
data = inject<FieldVisitDetailsDialogData>(MAT_DIALOG_DATA);
|
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 {
|
get statusBadgeClass(): string {
|
||||||
if (this.data.check.status === 'VERIFIED') {
|
if (this.data.check.status === 'VERIFIED') {
|
||||||
return 'fvd-badge fvd-badge--verified';
|
return 'fvd-badge fvd-badge--verified';
|
||||||
@@ -54,4 +69,63 @@ export class FieldVisitDetailsDialogComponent {
|
|||||||
}
|
}
|
||||||
return 'fvd-badge fvd-badge--pending';
|
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 {
|
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;
|
applicationNo: string;
|
||||||
applicationDate: string;
|
applicationDate: string;
|
||||||
applicantName: string;
|
applicantName: string;
|
||||||
@@ -225,6 +228,12 @@ export interface LoanApplication {
|
|||||||
eligibleLoanAmount: number;
|
eligibleLoanAmount: number;
|
||||||
coBorrowerRequired: boolean;
|
coBorrowerRequired: boolean;
|
||||||
coBorrowerName: string;
|
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.
|
// 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
|
/** RBI's Fair Practices Code requires the customer's informed consent before a credit bureau
|
||||||
@@ -354,6 +363,14 @@ export interface LoanApplication {
|
|||||||
achConfirmationRef: string;
|
achConfirmationRef: string;
|
||||||
emiActivationConfirmed: boolean;
|
emiActivationConfirmed: boolean;
|
||||||
emiActivationDate: string;
|
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;
|
currentStage: OriginationStage;
|
||||||
/** The furthest stage this application has ever reached, as a STAGE_ORDER index -- kept
|
/** The furthest stage this application has ever reached, as a STAGE_ORDER index -- kept
|
||||||
@@ -936,6 +953,9 @@ function generateApplications(count: number): LoanApplication[] {
|
|||||||
eligibleLoanAmount,
|
eligibleLoanAmount,
|
||||||
coBorrowerRequired: eligibleLoanAmount < requestedAmount,
|
coBorrowerRequired: eligibleLoanAmount < requestedAmount,
|
||||||
coBorrowerName: '—',
|
coBorrowerName: '—',
|
||||||
|
coBorrowerWanted: false,
|
||||||
|
coBorrowerRelationship: '',
|
||||||
|
coBorrowerMonthlyIncome: 0,
|
||||||
cicConsentSigned: false,
|
cicConsentSigned: false,
|
||||||
cicChargeCollected: false,
|
cicChargeCollected: false,
|
||||||
cicChargeMode: null,
|
cicChargeMode: null,
|
||||||
@@ -1011,6 +1031,9 @@ function generateApplications(count: number): LoanApplication[] {
|
|||||||
achConfirmationRef: '—',
|
achConfirmationRef: '—',
|
||||||
emiActivationConfirmed: false,
|
emiActivationConfirmed: false,
|
||||||
emiActivationDate: '—',
|
emiActivationDate: '—',
|
||||||
|
fineractLoanId: null,
|
||||||
|
fineractLoanAccountNo: null,
|
||||||
|
fineractLoanError: null,
|
||||||
currentStage: 'APPLICATION',
|
currentStage: 'APPLICATION',
|
||||||
furthestStageIndex: 0,
|
furthestStageIndex: 0,
|
||||||
overallStatus: 'IN_PROGRESS',
|
overallStatus: 'IN_PROGRESS',
|
||||||
@@ -1388,9 +1411,15 @@ function generateApplications(count: number): LoanApplication[] {
|
|||||||
return apps;
|
return apps;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sized like a real cooperative bank's active sanctioning pipeline — enough to need filters
|
/** Builds the demo book that seeds an empty pipeline on first use — sized like a real cooperative
|
||||||
// and pagination, not so many it stops reading as "an active queue".
|
* bank's active sanctioning queue: enough to need filters and pagination, not so many it stops
|
||||||
export const LOAN_APPLICATIONS: LoanApplication[] = generateApplications(140);
|
* 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
|
// 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
|
// 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 {
|
export interface NewApplicationInput {
|
||||||
channel: Channel;
|
channel: Channel;
|
||||||
@@ -1839,6 +1868,9 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
|
|||||||
eligibleLoanAmount: 0,
|
eligibleLoanAmount: 0,
|
||||||
coBorrowerRequired: false,
|
coBorrowerRequired: false,
|
||||||
coBorrowerName: '—',
|
coBorrowerName: '—',
|
||||||
|
coBorrowerWanted: false,
|
||||||
|
coBorrowerRelationship: '',
|
||||||
|
coBorrowerMonthlyIncome: 0,
|
||||||
cicConsentSigned: false,
|
cicConsentSigned: false,
|
||||||
cicChargeCollected: false,
|
cicChargeCollected: false,
|
||||||
cicChargeMode: null,
|
cicChargeMode: null,
|
||||||
@@ -1914,6 +1946,9 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
|
|||||||
achConfirmationRef: '—',
|
achConfirmationRef: '—',
|
||||||
emiActivationConfirmed: false,
|
emiActivationConfirmed: false,
|
||||||
emiActivationDate: '—',
|
emiActivationDate: '—',
|
||||||
|
fineractLoanId: null,
|
||||||
|
fineractLoanAccountNo: null,
|
||||||
|
fineractLoanError: null,
|
||||||
currentStage: 'APPLICATION',
|
currentStage: 'APPLICATION',
|
||||||
furthestStageIndex: 0,
|
furthestStageIndex: 0,
|
||||||
overallStatus: 'IN_PROGRESS',
|
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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
|||||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||||
import { ClientsService } from '../../clients/clients.service';
|
import { ClientsService } from '../../clients/clients.service';
|
||||||
import { ExportSheetSpec, exportWorkbook } from '../../cashier/cashier-export';
|
import { ExportSheetSpec, exportWorkbook } from '../../cashier/cashier-export';
|
||||||
|
import { LoanOriginationService } from '../loan-origination.service';
|
||||||
import {
|
import {
|
||||||
BRANCHES,
|
BRANCHES,
|
||||||
BranchMaster,
|
BranchMaster,
|
||||||
@@ -21,8 +22,8 @@ import {
|
|||||||
CHANNEL_LABELS,
|
CHANNEL_LABELS,
|
||||||
CHANNEL_TARGET_PCT,
|
CHANNEL_TARGET_PCT,
|
||||||
CustomerType,
|
CustomerType,
|
||||||
|
generateSeedApplications,
|
||||||
LoanApplication,
|
LoanApplication,
|
||||||
LOAN_APPLICATIONS,
|
|
||||||
ORIGINATION_PRODUCTS,
|
ORIGINATION_PRODUCTS,
|
||||||
OriginationStage,
|
OriginationStage,
|
||||||
OverallStatus,
|
OverallStatus,
|
||||||
@@ -99,9 +100,8 @@ type ViewId = 'pipeline' | 'new' | 'channel' | 'bc' | 'share' | 'target' | 'rate
|
|||||||
* workflow (Application → KYC → CIC Check → Field Investigation → Personal Discussion → Credit
|
* workflow (Application → KYC → CIC Check → Field Investigation → Personal Discussion → Credit
|
||||||
* Committee → Risk Approval → Legal Approval → Customer Signing → Cheque/ACH → Post-Check →
|
* 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
|
* 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
|
* intake form. Every count/total here is derived from the same applications loaded from the
|
||||||
* Application Detail screen mutates, so the pipeline view can never drift from an application's
|
* database, so the pipeline view can never drift from an application's real state.
|
||||||
* real state.
|
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-loan-pipeline',
|
selector: 'mifosx-loan-pipeline',
|
||||||
@@ -188,6 +188,13 @@ export class LoanPipelineComponent implements OnInit {
|
|||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly auth = inject(AuthenticationService);
|
private readonly auth = inject(AuthenticationService);
|
||||||
private readonly clientsService = inject(ClientsService);
|
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 {
|
ngOnInit(): void {
|
||||||
const requestedView = this.route.snapshot.queryParamMap.get('view') as ViewId | null;
|
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.
|
// not just hidden, since the New Application form posts whatever branchCode is bound.
|
||||||
this.newApp.branchCode = this.branchFilter;
|
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
|
/** 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[] {
|
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[] {
|
get filteredApplications(): LoanApplication[] {
|
||||||
@@ -407,10 +460,9 @@ export class LoanPipelineComponent implements OnInit {
|
|||||||
? this.branches
|
? this.branches
|
||||||
: this.branches.filter((b) => b.code === this.branchFilter);
|
: this.branches.filter((b) => b.code === this.branchFilter);
|
||||||
return branchesToShow.map((b) => {
|
return branchesToShow.map((b) => {
|
||||||
const actual = LOAN_APPLICATIONS.filter((a) => a.branchCode === b.code && a.overallStatus === 'DISBURSED').reduce(
|
const actual = this.applications
|
||||||
(s, a) => s + a.requestedAmount,
|
.filter((a) => a.branchCode === b.code && a.overallStatus === 'DISBURSED')
|
||||||
0
|
.reduce((s, a) => s + a.requestedAmount, 0);
|
||||||
);
|
|
||||||
const target = branchTargetAmount(b.code);
|
const target = branchTargetAmount(b.code);
|
||||||
const achievementPct = target ? (actual / target) * 100 : 0;
|
const achievementPct = target ? (actual / target) * 100 : 0;
|
||||||
return {
|
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 branch = this.branches.find((b) => b.code === this.newApp.branchCode) || this.branches[0];
|
||||||
const product = this.productOf(this.newApp.productCode);
|
const product = this.productOf(this.newApp.productCode);
|
||||||
const seq = LOAN_APPLICATIONS.length + 20000;
|
const seq = this.applications.length + 20000;
|
||||||
const app = createApplication(
|
const app = createApplication(
|
||||||
{
|
{
|
||||||
channel: this.newApp.channel,
|
channel: this.newApp.channel,
|
||||||
@@ -578,8 +630,14 @@ export class LoanPipelineComponent implements OnInit {
|
|||||||
},
|
},
|
||||||
seq
|
seq
|
||||||
);
|
);
|
||||||
LOAN_APPLICATIONS.unshift(app);
|
// Only navigate once the application is actually stored -- an officer who lands on the detail
|
||||||
this.toast(`Application ${app.applicationNo} received for ${app.applicantName}.`);
|
// 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 = {
|
this.newApp = {
|
||||||
channel: 'BRANCH',
|
channel: 'BRANCH',
|
||||||
bcAgentName: '',
|
bcAgentName: '',
|
||||||
@@ -596,8 +654,16 @@ export class LoanPipelineComponent implements OnInit {
|
|||||||
this.existingClientSearchResults = [];
|
this.existingClientSearchResults = [];
|
||||||
this.router.navigate([
|
this.router.navigate([
|
||||||
'/loan-origination/application',
|
'/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 --------------------
|
// -------------------- Export to Excel --------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user