feat(loan-origination): wire pipeline and application detail to real branches and saved products

New loan application now sources branches and product type/sub-type/code
options from Product Creation's saved products (Gold, Warehouse Receipt,
Loans/EMI) and real office branches instead of hardcoded lists, with
tenure constrained to each product's min/max.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 17:59:34 +05:30
co-authored by Claude Sonnet 5
parent ec550b1798
commit 9bde697925
11 changed files with 2999 additions and 330 deletions
@@ -13,9 +13,10 @@ 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 { LoanOriginationService, officeBranchCode } from '../loan-origination.service';
import { LoanProductCatalogService } from '../../loans/services/loan-product-catalog.service';
import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS } from '../../products/loan-products/loan-product-types';
import {
BRANCHES,
BranchMaster,
branchTargetAmount,
Channel,
@@ -25,10 +26,10 @@ import {
generateSeedApplications,
LoanApplication,
ORIGINATION_PRODUCTS,
registerOriginationProduct,
OriginationStage,
OverallStatus,
PRODUCT_TARGET_PCT,
PRODUCT_TYPE_LABELS,
ProductType,
SHARE_LINKAGE_SLABS,
STAGE_LABELS,
@@ -36,9 +37,7 @@ import {
STATUS_LABELS,
TARGET_STATUS_LABELS,
TargetStatus,
allowedProductTypes,
createApplication,
defaultProductType,
progressPct,
stageBadgeClass,
targetStatus,
@@ -124,7 +123,12 @@ export class LoanPipelineComponent implements OnInit {
readonly stageLabels = STAGE_LABELS;
readonly statusLabels = STATUS_LABELS;
readonly channelLabels = CHANNEL_LABELS;
readonly branches = BRANCHES;
/** The bank's offices (real). Until they load, the logged-in user's own office is the only choice. */
branches: BranchMaster[] = [];
/** Active products saved in Product Creation -- the only products an application can be taken for. */
dbProducts: any[] = [];
loadingProducts = true;
readonly productGroups = PRODUCT_TYPE_GROUPS;
readonly products = ORIGINATION_PRODUCTS;
readonly shareLinkageSlabs = SHARE_LINKAGE_SLABS;
asOnDate = todayDDMMYYYY();
@@ -152,26 +156,63 @@ export class LoanPipelineComponent implements OnInit {
customerType: 'NEW' as CustomerType,
applicantName: '',
clientId: null as number | null,
branchCode: this.branches[0].code,
productCode: this.products[0].code,
productType: defaultProductType(this.products[0].code),
branchCode: '',
productGroup: '',
productSubType: '',
productCode: '',
productType: 'TERM_LOAN' as ProductType,
requestedAmount: null as number | null,
tenureMonths: null as number | null
};
readonly productTypeLabels = PRODUCT_TYPE_LABELS;
/** Which Product Types the currently-selected product can be sanctioned as -- Cash Credit/KCC
* are Overdraft-only, Gold Loan offers both, everything else is Term Loan-only. */
get newAppProductTypeOptions(): ProductType[] {
return allowedProductTypes(this.newApp.productCode);
/** Sub types of the chosen Product Type that actually have an active product saved. */
get subTypeOptions() {
const group = this.newApp.productGroup;
return (PRODUCT_SUB_TYPES[group] ?? []).filter((t) =>
this.dbProducts.some(
(p) => p.advanceExtensions?.productTypeGroup === group && p.advanceExtensions?.productTypeSubGroup === t.id
)
);
}
/** Switching Product can change which Product Types are even valid -- reset to that product's
* default rather than leaving a stale selection (e.g. "Overdraft" left over from Gold Loan)
* on a product that doesn't offer it. */
/** The products saved in the database for the chosen Product Type and Sub Type. */
get productOptions(): any[] {
return this.dbProducts.filter(
(p) =>
p.advanceExtensions?.productTypeGroup === this.newApp.productGroup &&
p.advanceExtensions?.productTypeSubGroup === this.newApp.productSubType
);
}
/** Only the tenures the chosen product allows (its min to max, in months). */
get tenureOptions(): number[] {
const p = this.selectedProduct;
if (!p) {
return [];
}
return Array.from({ length: Math.max(0, p.maxTenure - p.minTenure + 1) }, (_, i) => p.minTenure + i);
}
get selectedProduct() {
return this.products.find((p) => p.code === this.newApp.productCode) || null;
}
onProductGroupChange(): void {
this.newApp.productSubType = '';
this.newApp.productCode = '';
}
onSubTypeChange(): void {
const options = this.productOptions;
this.newApp.productCode = options.length === 1 ? options[0].shortName : '';
this.onProductChange();
}
/** Advances have no EMI (revolving limit); Loans are EMI term loans. */
onProductChange(): void {
this.newApp.productType = defaultProductType(this.newApp.productCode);
this.newApp.productType = this.newApp.productGroup === 'advances' ? 'OVERDRAFT' : 'TERM_LOAN';
const product = this.selectedProduct;
this.newApp.tenureMonths = product ? product.minTenure : null;
}
/** Existing-customer search (real Fineract clients, unlike the rest of this mock pipeline). */
@@ -189,6 +230,7 @@ export class LoanPipelineComponent implements OnInit {
private readonly auth = inject(AuthenticationService);
private readonly clientsService = inject(ClientsService);
private readonly originationService = inject(LoanOriginationService);
private readonly catalog = inject(LoanProductCatalogService);
/** 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. */
@@ -221,17 +263,27 @@ export class LoanPipelineComponent implements OnInit {
const creds = this.auth.getCredentials();
this.isHeadOffice = this.auth.isHeadOfficeUser();
this.currentOfficeName = creds?.officeName || 'Branch Office';
if (this.isHeadOffice) {
this.branchFilter = '';
} else {
this.ownBranchCode =
this.branches.find((b: BranchMaster) => this.currentOfficeName.toLowerCase().includes(b.name.toLowerCase()))
?.code || null;
this.branchFilter = this.ownBranchCode || this.branches[0].code;
// A branch-level login can only originate applications for its own branch — locked here,
// not just hidden, since the New Application form posts whatever branchCode is bound.
this.newApp.branchCode = this.branchFilter;
}
this.ownBranchCode = creds?.officeId ? officeBranchCode(creds.officeId) : null;
// The logged-in user's own office is the default branch. A branch-level login can only originate for its own branch:
// the select is disabled and `saveNewApplication` forces the code again, so it is locked here and not just hidden.
this.branches = this.ownBranchCode ? [{ code: this.ownBranchCode, name: this.currentOfficeName }] : [];
this.branchFilter = this.isHeadOffice ? '' : this.ownBranchCode || '';
this.newApp.branchCode = this.ownBranchCode || '';
this.catalog.activeProducts().subscribe((products) => {
this.dbProducts = products.filter((p) => p.shortName && p.advanceExtensions?.productTypeGroup);
this.dbProducts.forEach((p) => registerOriginationProduct(p));
this.loadingProducts = false;
});
this.originationService.listBranches().subscribe({
next: (offices) => {
if (offices.length > 0) {
this.branches = offices;
}
},
error: () => {
/* keep the user's own office as the only choice */
}
});
this.loadApplications();
}
@@ -310,6 +362,42 @@ export class LoanPipelineComponent implements OnInit {
return this.branchFilter ? this.applications.filter((a) => a.branchCode === this.branchFilter) : this.applications;
}
private get currentUserName(): string {
return (this.auth.getCredentials()?.username ?? '').toLowerCase();
}
/** Applications with an approval sent and waiting for a decision (maker-checker), newest first. */
get awaitingApproval() {
const rows: {
app: LoanApplication;
stage: string;
sentBy: string;
sentOn: string;
mine: boolean;
exception: boolean;
}[] = [];
this.applications.forEach((app) => {
Object.entries(app.approvalRequests ?? {}).forEach(
([
,
request
]) => {
if (request && request.status === 'PENDING') {
rows.push({
app,
stage: STAGE_LABELS[app.currentStage],
sentBy: request.submittedBy,
sentOn: request.submittedOn,
mine: (request.submittedBy ?? '').toLowerCase() === this.currentUserName,
exception: request.requestedDecision === 'EXCEPTION_APPROVED'
});
}
}
);
});
return rows;
}
get filteredApplications(): LoanApplication[] {
return this.scopedApplications.filter((a) => {
if (this.channelFilter && a.channel !== this.channelFilter) {
@@ -543,7 +631,7 @@ export class LoanPipelineComponent implements OnInit {
// -------------------- New Application intake --------------------
productOf(code: string) {
return this.products.find((p) => p.code === code) || this.products[0];
return this.products.find((p) => p.code === code);
}
/** Switching Customer Type away from "Existing" drops any client picked under the old mode --
@@ -608,8 +696,27 @@ export class LoanPipelineComponent implements OnInit {
if (!this.isHeadOffice && this.ownBranchCode) {
this.newApp.branchCode = this.ownBranchCode;
}
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] || { code: this.newApp.branchCode, name: this.currentOfficeName };
const product = this.productOf(this.newApp.productCode);
if (!product) {
this.toast('Select the Product Type, Sub Type and Product.');
return;
}
if (
this.newApp.requestedAmount &&
(this.newApp.requestedAmount < product.minAmt || this.newApp.requestedAmount > product.maxAmt)
) {
this.toast(
`Amount must be between ₹${product.minAmt.toLocaleString('en-IN')} and ₹${product.maxAmt.toLocaleString('en-IN')} for ${product.name}.`
);
return;
}
const tenure = this.newApp.tenureMonths || product.minTenure;
if (tenure < product.minTenure || tenure > product.maxTenure) {
this.toast(`Tenure must be between ${product.minTenure} and ${product.maxTenure} months for ${product.name}.`);
return;
}
const seq = this.applications.length + 20000;
const app = createApplication(
{
@@ -644,9 +751,11 @@ export class LoanPipelineComponent implements OnInit {
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),
branchCode: this.ownBranchCode || this.branches[0]?.code || '',
productGroup: '',
productSubType: '',
productCode: '',
productType: 'TERM_LOAN',
requestedAmount: null,
tenureMonths: null
};