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:
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user