feat(loan-origination): persist the sanctioning pipeline to the backend
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

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:
2026-08-17 17:05:05 +05:30
co-authored by Claude Sonnet 5
parent 2d93f80d50
commit c18219fce5
10 changed files with 1333 additions and 301 deletions
@@ -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 &amp; Confirm EMI Activation
} @else {
Disburse Now &amp; Confirm EMI Activation Disburse Now &amp; 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) --------------------
@@ -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>
@@ -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;
@@ -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 --------------------