feat(loan-origination): persist the sanctioning pipeline to the backend
Replaces the in-memory mock (LOAN_APPLICATIONS array + seeded RNG, reset on every refresh) with real Fineract-backed persistence: a new LoanOriginationService for CRUD plus one API call per stage transition, a loan-fineract-bridge that creates a real loan account (create + approve + disburse) at disbursement for Existing Customers, and file uploads through Fineract's generic document store keyed by a stable checklist slot so "On file"/View controls survive a reload. Also fixes several gaps found while verifying full-cycle persistence: - CIC charge-account selection never saved (missing persist() call) - Co-borrower relationship/monthly income lived in local-only component state instead of on the application, so they reset on refresh - Field Investigation "View Full Details" showed local-session photos only; stored photos are now downloaded and shown as real thumbnails - "Assign Field Officer" was free text; now a dropdown of real CBS loan officers pulled from /staff - site photos and cheque/signature images now open in the same zoomable, pannable lightgallery viewer used elsewhere in the app Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 --------------------
|
||||
|
||||
Reference in New Issue
Block a user