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
@@ -13,6 +13,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ClientsService } from '../../clients/clients.service';
import { ExportSheetSpec, exportWorkbook } from '../../cashier/cashier-export';
import { LoanOriginationService } from '../loan-origination.service';
import {
BRANCHES,
BranchMaster,
@@ -21,8 +22,8 @@ import {
CHANNEL_LABELS,
CHANNEL_TARGET_PCT,
CustomerType,
generateSeedApplications,
LoanApplication,
LOAN_APPLICATIONS,
ORIGINATION_PRODUCTS,
OriginationStage,
OverallStatus,
@@ -99,9 +100,8 @@ type ViewId = 'pipeline' | 'new' | 'channel' | 'bc' | 'share' | 'target' | 'rate
* workflow (Application → KYC → CIC Check → Field Investigation → Personal Discussion → Credit
* Committee → Risk Approval → Legal Approval → Customer Signing → Cheque/ACH → Post-Check →
* Disbursed), plus the Channel-wise / BC-wise / Share Member reports and a New Application
* intake form. Every count/total here is derived from the same LOAN_APPLICATIONS array the
* Application Detail screen mutates, so the pipeline view can never drift from an application's
* real state.
* intake form. Every count/total here is derived from the same applications loaded from the
* database, so the pipeline view can never drift from an application's real state.
*/
@Component({
selector: 'mifosx-loan-pipeline',
@@ -188,6 +188,13 @@ export class LoanPipelineComponent implements OnInit {
private readonly router = inject(Router);
private readonly auth = inject(AuthenticationService);
private readonly clientsService = inject(ClientsService);
private readonly originationService = inject(LoanOriginationService);
/** The live book, loaded from the database. Every count, report and filter on this screen derives
* from this one array, so the list can never disagree with what an application actually says. */
applications: LoanApplication[] = [];
loadingApplications = false;
savingNewApplication = false;
ngOnInit(): void {
const requestedView = this.route.snapshot.queryParamMap.get('view') as ViewId | null;
@@ -225,6 +232,52 @@ export class LoanPipelineComponent implements OnInit {
// not just hidden, since the New Application form posts whatever branchCode is bound.
this.newApp.branchCode = this.branchFilter;
}
this.loadApplications();
}
/** Loads the book from the database. On a completely fresh install the pipeline is empty, so the
* demo book is seeded once and then re-read — the seed endpoint no-ops when rows already exist,
* so reloading this screen can never duplicate it. */
private loadApplications(): void {
this.loadingApplications = true;
this.originationService.list().subscribe({
next: (apps: LoanApplication[]) => {
if (apps.length === 0) {
this.seedThenReload();
return;
}
this.applications = apps;
this.loadingApplications = false;
},
error: () => {
this.applications = [];
this.loadingApplications = false;
this.toast('Could not load the loan origination pipeline.');
}
});
}
private seedThenReload(): void {
this.originationService.seed(generateSeedApplications()).subscribe({
next: () => {
this.originationService.list().subscribe({
next: (seeded: LoanApplication[]) => {
this.applications = seeded;
this.loadingApplications = false;
},
error: () => {
this.applications = [];
this.loadingApplications = false;
}
});
},
error: () => {
this.applications = [];
this.loadingApplications = false;
this.toast('Could not initialise the loan origination pipeline.');
}
});
}
/** Branches selectable on the New Application form — every branch for Head Office, only the
@@ -254,7 +307,7 @@ export class LoanPipelineComponent implements OnInit {
}
get scopedApplications(): LoanApplication[] {
return this.branchFilter ? LOAN_APPLICATIONS.filter((a) => a.branchCode === this.branchFilter) : LOAN_APPLICATIONS;
return this.branchFilter ? this.applications.filter((a) => a.branchCode === this.branchFilter) : this.applications;
}
get filteredApplications(): LoanApplication[] {
@@ -407,10 +460,9 @@ export class LoanPipelineComponent implements OnInit {
? this.branches
: this.branches.filter((b) => b.code === this.branchFilter);
return branchesToShow.map((b) => {
const actual = LOAN_APPLICATIONS.filter((a) => a.branchCode === b.code && a.overallStatus === 'DISBURSED').reduce(
(s, a) => s + a.requestedAmount,
0
);
const actual = this.applications
.filter((a) => a.branchCode === b.code && a.overallStatus === 'DISBURSED')
.reduce((s, a) => s + a.requestedAmount, 0);
const target = branchTargetAmount(b.code);
const achievementPct = target ? (actual / target) * 100 : 0;
return {
@@ -558,7 +610,7 @@ export class LoanPipelineComponent implements OnInit {
}
const branch = this.branches.find((b) => b.code === this.newApp.branchCode) || this.branches[0];
const product = this.productOf(this.newApp.productCode);
const seq = LOAN_APPLICATIONS.length + 20000;
const seq = this.applications.length + 20000;
const app = createApplication(
{
channel: this.newApp.channel,
@@ -578,26 +630,40 @@ export class LoanPipelineComponent implements OnInit {
},
seq
);
LOAN_APPLICATIONS.unshift(app);
this.toast(`Application ${app.applicationNo} received for ${app.applicantName}.`);
this.newApp = {
channel: 'BRANCH',
bcAgentName: '',
customerType: 'NEW',
applicantName: '',
clientId: null,
branchCode: this.isHeadOffice ? this.branches[0].code : this.ownBranchCode || this.branches[0].code,
productCode: this.products[0].code,
productType: defaultProductType(this.products[0].code),
requestedAmount: null,
tenureMonths: null
};
this.existingClientSearchText = '';
this.existingClientSearchResults = [];
this.router.navigate([
'/loan-origination/application',
app.applicationNo
]);
// Only navigate once the application is actually stored -- an officer who lands on the detail
// screen for something that failed to save would fill in a whole stage against nothing.
this.savingNewApplication = true;
this.originationService.create(app).subscribe({
next: (saved: LoanApplication) => {
this.savingNewApplication = false;
this.applications.unshift(saved);
this.toast(`Application ${saved.applicationNo} received for ${saved.applicantName}.`);
this.newApp = {
channel: 'BRANCH',
bcAgentName: '',
customerType: 'NEW',
applicantName: '',
clientId: null,
branchCode: this.isHeadOffice ? this.branches[0].code : this.ownBranchCode || this.branches[0].code,
productCode: this.products[0].code,
productType: defaultProductType(this.products[0].code),
requestedAmount: null,
tenureMonths: null
};
this.existingClientSearchText = '';
this.existingClientSearchResults = [];
this.router.navigate([
'/loan-origination/application',
saved.applicationNo
]);
},
error: (err: any) => {
this.savingNewApplication = false;
this.toast(
err?.error?.errors?.[0]?.defaultUserMessage || 'Could not save the application. Nothing was recorded.'
);
}
});
}
// -------------------- Export to Excel --------------------