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:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -50,7 +50,8 @@ export async function createRealLoanAccount(
|
||||
|
||||
const locale = settings.language.code;
|
||||
const dateFormat = settings.dateFormat;
|
||||
const today = dateUtils.formatDate(new Date(), dateFormat);
|
||||
// Fineract rejects a submitted-on date later than its business date, so use that (the date shown in the header), not the PC clock.
|
||||
const today = dateUtils.formatDate(settings.businessDate, dateFormat);
|
||||
|
||||
const payload = {
|
||||
clientId: app.clientId,
|
||||
|
||||
@@ -29,6 +29,7 @@ export { BRANCHES, BranchMaster };
|
||||
export type OriginationStage =
|
||||
| 'APPLICATION'
|
||||
| 'KYC_DOCUMENTS'
|
||||
| 'DEPOSIT_SELECTION'
|
||||
| 'CIC_CHECK'
|
||||
| 'FIELD_INVESTIGATION'
|
||||
| 'PERSONAL_DISCUSSION'
|
||||
@@ -43,6 +44,7 @@ export type OriginationStage =
|
||||
export const STAGE_ORDER: OriginationStage[] = [
|
||||
'APPLICATION',
|
||||
'KYC_DOCUMENTS',
|
||||
'DEPOSIT_SELECTION',
|
||||
'CIC_CHECK',
|
||||
'FIELD_INVESTIGATION',
|
||||
'PERSONAL_DISCUSSION',
|
||||
@@ -58,6 +60,7 @@ export const STAGE_ORDER: OriginationStage[] = [
|
||||
export const STAGE_LABELS: Record<OriginationStage, string> = {
|
||||
APPLICATION: 'Application Received',
|
||||
KYC_DOCUMENTS: 'KYC & Document Collection',
|
||||
DEPOSIT_SELECTION: 'Deposit Selection & Eligibility',
|
||||
CIC_CHECK: 'CIC / Credit Bureau Check',
|
||||
FIELD_INVESTIGATION: 'Field Investigation (FI)',
|
||||
PERSONAL_DISCUSSION: 'Personal Discussion with Manager',
|
||||
@@ -70,6 +73,18 @@ export const STAGE_LABELS: Record<OriginationStage, string> = {
|
||||
DISBURSED: 'Disbursed'
|
||||
};
|
||||
|
||||
/** The stages a given product actually goes through (a loan against a deposit has no CIC and no field investigation): Deposit Selection only for a loan against a deposit, and no
|
||||
* credit bureau (CIC) check for it. Everything else keeps the full 12-stage list. */
|
||||
export function stagesFor(productCode: string | undefined | null): OriginationStage[] {
|
||||
if (isDepositLoanCode(productCode)) {
|
||||
// No credit bureau check, field investigation or security cheque / ACH (recovery is by set-off against the lien); every other stage stays.
|
||||
return STAGE_ORDER.filter(
|
||||
(stage) => stage !== 'CIC_CHECK' && stage !== 'FIELD_INVESTIGATION' && stage !== 'CHEQUE_ACH'
|
||||
);
|
||||
}
|
||||
return STAGE_ORDER.filter((stage) => stage !== 'DEPOSIT_SELECTION');
|
||||
}
|
||||
|
||||
export function stageIndex(stage: OriginationStage): number {
|
||||
return STAGE_ORDER.indexOf(stage);
|
||||
}
|
||||
@@ -180,7 +195,54 @@ export interface StageHistoryEntry {
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
/** The deposit(s) pledged for a loan against FD / RD / DDS / LIC and what they allow (policy 9.5). */
|
||||
export interface DepositSelection {
|
||||
accountIds: number[];
|
||||
accountNos: string[];
|
||||
securityValue: number;
|
||||
limitPct: number;
|
||||
eligibleAmount: number;
|
||||
depositRate: number;
|
||||
spread: number;
|
||||
maxTenure: number;
|
||||
over85: boolean;
|
||||
over85Reason: string;
|
||||
thirdParty: boolean;
|
||||
/** The product requires a lien on the deposit (a product can switch this off). */
|
||||
lienRequired: boolean;
|
||||
saved: boolean;
|
||||
}
|
||||
|
||||
/** Lien noted on the pledged deposit(s) for this application. */
|
||||
export interface LienRecord {
|
||||
marked: boolean;
|
||||
certificateNo: string;
|
||||
markedOn: string;
|
||||
amount: number;
|
||||
accountNos: string[];
|
||||
}
|
||||
|
||||
/** An approval sent for a decision (maker-checker): the user who sent it cannot be the one who decides. */
|
||||
export interface ApprovalRequest {
|
||||
status: 'PENDING' | 'APPROVED' | 'EXCEPTION_APPROVED' | 'REJECTED' | 'WITHDRAWN';
|
||||
submittedBy: string;
|
||||
submittedOn: string;
|
||||
requestedDecision: 'APPROVED' | 'EXCEPTION_APPROVED';
|
||||
remarks: string;
|
||||
resolutionNo?: string;
|
||||
resolutionDate?: string;
|
||||
/** What the sender had verified at the moment of sending -- what the approver reviews. */
|
||||
checklist?: { label: string; ok: boolean }[];
|
||||
/** What the approver ticked as checked herself/himself before approving. */
|
||||
checkerChecklist?: { label: string; ok: boolean }[];
|
||||
decidedBy?: string;
|
||||
decidedOn?: string;
|
||||
}
|
||||
|
||||
export interface LoanApplication {
|
||||
approvalRequests?: Partial<Record<string, ApprovalRequest>>;
|
||||
depositSelection?: DepositSelection;
|
||||
lien?: LienRecord;
|
||||
/** 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;
|
||||
@@ -451,7 +513,47 @@ interface OriginationProduct {
|
||||
/** Minimum Share Capital the borrower must hold, as a % of the loan amount, per bank bye-laws
|
||||
* (a UCB norm -- membership/share-linkage against borrowing, distinct from margin/collateral). */
|
||||
shareLinkagePercent: number;
|
||||
/** Loan against a deposit: the share of the deposit value that can be lent (policy 9.5.5: 85%). */
|
||||
ltvPct?: number;
|
||||
/** Loan against a deposit: percentage points added to the deposit's own rate. */
|
||||
spread?: number;
|
||||
/** Loan against a deposit: when the lien is marked, as set on the product ("disbursement" or "sanction"). */
|
||||
lienStage?: string;
|
||||
lienRequired?: boolean;
|
||||
/** What the eligible amount is calculated on: "balance_accrued" (balance + accrued interest) or "balance". */
|
||||
eligibleBasis?: string;
|
||||
/** "deposit_spread" = the deposit's own rate plus the spread; anything else uses the product's own rate. */
|
||||
interestBasis?: string;
|
||||
/** The product's own interest rate (used when the rate is not "deposit rate + spread"). */
|
||||
baseRate?: number;
|
||||
allowMultipleDeposits?: boolean;
|
||||
allowThirdParty?: boolean;
|
||||
/** The loan tenure may not run past the deposit's maturity. */
|
||||
tenureWithinMaturity?: boolean;
|
||||
certificateRequired?: boolean;
|
||||
certificatePrefix?: string;
|
||||
/** Terms shown on the loan plan of a deposit loan. */
|
||||
penalRate?: number;
|
||||
penalApplicable?: boolean;
|
||||
repaymentType?: string;
|
||||
interestPostingFrequency?: string;
|
||||
facilityType?: string;
|
||||
preclosure?: string;
|
||||
renewalAllowed?: boolean;
|
||||
}
|
||||
/** Short names of the loans against a deposit or policy (FD, RD, DDS, LIC): the deposit is the security, so income
|
||||
* proof, bank statement, address proof and a co-borrower are optional and there is no credit bureau check. */
|
||||
export const DEPOSIT_PRODUCT_CODES = [
|
||||
'FD',
|
||||
'RD',
|
||||
'DS',
|
||||
'LI'
|
||||
];
|
||||
|
||||
export function isDepositLoanCode(productCode: string | undefined | null): boolean {
|
||||
return !!productCode && DEPOSIT_PRODUCT_CODES.includes(productCode);
|
||||
}
|
||||
|
||||
export const ORIGINATION_PRODUCTS: OriginationProduct[] = [
|
||||
{
|
||||
code: 'PL',
|
||||
@@ -586,6 +688,53 @@ interface ProductDocumentTemplate {
|
||||
* encumbering that asset has to be collected before the loan can be sanctioned, on top of the
|
||||
* borrower's own identity/income proof. */
|
||||
export const PRODUCT_DOCUMENT_REQUIREMENTS: Record<string, ProductDocumentTemplate[]> = {
|
||||
FD: [
|
||||
{
|
||||
key: 'fdReceipt',
|
||||
label: 'FD receipt — original, discharged on the back',
|
||||
hint: 'Signed by the depositor'
|
||||
},
|
||||
{ key: 'dpNote', label: 'Demand promissory note (DP Note)', hint: 'Promise to repay on demand' },
|
||||
{
|
||||
key: 'letterOfContinuity',
|
||||
label: 'Letter of continuity',
|
||||
hint: 'Liability and security continue until the loan is repaid'
|
||||
}
|
||||
],
|
||||
RD: [
|
||||
{ key: 'rdPassbook', label: 'RD passbook', hint: 'Kept by the bank' },
|
||||
{
|
||||
key: 'adjustDeclaration',
|
||||
label: 'Declaration: bank may adjust the loan on RD maturity',
|
||||
hint: 'Bank may adjust the loan from the deposit on maturity'
|
||||
},
|
||||
{ key: 'dpNote', label: 'Demand promissory note (DP Note)', hint: 'Promise to repay on demand' },
|
||||
{
|
||||
key: 'letterOfContinuity',
|
||||
label: 'Letter of continuity',
|
||||
hint: 'Liability and security continue until the loan is repaid'
|
||||
}
|
||||
],
|
||||
DS: [
|
||||
{ key: 'ddsPassbook', label: 'DDS passbook', hint: 'Kept by the bank' },
|
||||
{
|
||||
key: 'adjustDeclaration',
|
||||
label: 'Declaration: bank may adjust the loan on DDS maturity',
|
||||
hint: 'Bank may adjust the loan from the deposit on maturity'
|
||||
},
|
||||
{ key: 'dpNote', label: 'Demand promissory note (DP Note)', hint: 'Promise to repay on demand' },
|
||||
{
|
||||
key: 'letterOfContinuity',
|
||||
label: 'Letter of continuity',
|
||||
hint: 'Liability and security continue until the loan is repaid'
|
||||
}
|
||||
],
|
||||
LI: [
|
||||
{ key: 'licPolicyBond', label: 'Original LIC policy bond', hint: 'Kept by the bank' },
|
||||
{ key: 'licAssignment', label: 'Assignment deed in favour of the bank', hint: 'Registered with the LIC branch' },
|
||||
{ key: 'licSurrenderLetter', label: 'LIC surrender value letter', hint: 'Not older than 1 month' },
|
||||
{ key: 'dpNote', label: 'Demand promissory note (DP Note)', hint: 'Promise to repay on demand' }
|
||||
],
|
||||
HL: [
|
||||
{ key: 'propertyTaxReceipt', label: 'Property Tax Receipt' },
|
||||
{ key: 'propertyRegistration', label: 'Property Registration Document (Sale Deed)' },
|
||||
@@ -636,15 +785,75 @@ export function buildProductDocuments(productCode: string): ProductDocumentEntry
|
||||
return (PRODUCT_DOCUMENT_REQUIREMENTS[productCode] ?? []).map((doc) => ({ ...doc, custom: false, submitted: false }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a product saved in Product Creation usable by the application screens: the detail page, the eligibility
|
||||
* check and the Fineract bridge all look a product up by its code in ORIGINATION_PRODUCTS, so a saved product is
|
||||
* registered there under its Short Name (replacing an earlier registration of the same code).
|
||||
*/
|
||||
export function registerOriginationProduct(p: any): OriginationProduct {
|
||||
const ext = p?.advanceExtensions ?? {};
|
||||
const inMonths = !p?.repaymentFrequencyType?.value || p.repaymentFrequencyType.value === 'Months';
|
||||
const rate = Number(p?.interestRatePerPeriod ?? 0);
|
||||
const entry: OriginationProduct = {
|
||||
code: String(p.shortName),
|
||||
name: String(p.name).replace(/\s+/g, ' ').trim(),
|
||||
secured: !isDepositLoanCode(String(p.shortName)) && (ext.productTypeGroup === 'advances' || !!ext.securityType),
|
||||
minAmt: Number(p?.minPrincipal ?? p?.principal ?? 0),
|
||||
maxAmt: Number(p?.maxPrincipal ?? p?.principal ?? 0),
|
||||
minRate: Number(p?.minInterestRatePerPeriod ?? rate),
|
||||
maxRate: Number(p?.maxInterestRatePerPeriod ?? rate),
|
||||
minTenure: Number(inMonths ? (p?.minNumberOfRepayments ?? p?.numberOfRepayments ?? 1) : 1),
|
||||
maxTenure: Number(inMonths ? (p?.maxNumberOfRepayments ?? p?.numberOfRepayments ?? 360) : 360),
|
||||
incomeMultiplier: 20,
|
||||
shareLinkagePercent: 5,
|
||||
ltvPct: Number(ext.ltv ?? 85),
|
||||
spread: Number(ext.spread ?? 0),
|
||||
lienStage: String(ext.lienMarkingStage ?? 'disbursement'),
|
||||
lienRequired: ext.lienRequired !== false,
|
||||
eligibleBasis: String(ext.eligibleAmountBasis ?? 'balance_accrued'),
|
||||
interestBasis: String(ext.interestBasis ?? 'deposit_spread'),
|
||||
baseRate: rate,
|
||||
allowMultipleDeposits: ext.lienAllowMultipleDeposits === true,
|
||||
allowThirdParty: ext.lienAllowThirdParty === true,
|
||||
tenureWithinMaturity: ext.lienTenureNotExceedMaturity !== false,
|
||||
certificateRequired: ext.lienCertificateRequired !== false && ext.lienCertificateRequired !== 'NO',
|
||||
certificatePrefix: ext.lienCertificatePrefix ? String(ext.lienCertificatePrefix) : undefined,
|
||||
penalRate: Number(ext.penalRate ?? 0),
|
||||
penalApplicable: ext.penalApplicable === true,
|
||||
repaymentType: String(ext.repaymentType ?? 'bullet'),
|
||||
interestPostingFrequency: String(ext.interestPostingFrequency ?? 'Monthly'),
|
||||
facilityType: String(ext.facilityType ?? 'demand'),
|
||||
preclosure: String(ext.preclosureAllowed ?? 'anytime'),
|
||||
renewalAllowed: ext.renewalAllowed === true
|
||||
};
|
||||
const at = ORIGINATION_PRODUCTS.findIndex((x) => x.code === entry.code);
|
||||
if (at >= 0) {
|
||||
ORIGINATION_PRODUCTS[at] = entry;
|
||||
} else {
|
||||
ORIGINATION_PRODUCTS.push(entry);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** What Legal Approval actually collects proof of -- the executed agreement, the stamp duty paid
|
||||
* on it, and (for a secured product) the mortgage/title paperwork -- same "Add More Doc" pattern
|
||||
* as the product-specific documents above, since a specific matter can always need one more
|
||||
* paper (a second mortgagor's consent letter, a court NOC, etc.) than this starter list expects. */
|
||||
export function buildLegalDocuments(secured: boolean): ProductDocumentEntry[] {
|
||||
export function buildLegalDocuments(secured: boolean, depositLoan = false): ProductDocumentEntry[] {
|
||||
if (depositLoan) {
|
||||
// The DP Note, letter of continuity, deposit receipt and letter of lien were collected earlier (KYC & Documents and
|
||||
// Lien Marking); only what is new at the legal stage is asked here. No mortgage, title or legal opinion: the security is a deposit.
|
||||
return [
|
||||
{
|
||||
key: 'stampDutyReceipt',
|
||||
label: 'Stamp duty / revenue stamp receipt (on the DP Note and agreement)'
|
||||
}
|
||||
].map((doc) => ({ ...doc, custom: false, submitted: false }));
|
||||
}
|
||||
const templates: ProductDocumentTemplate[] = [
|
||||
{ key: 'loanAgreement', label: 'Loan Agreement (Registered/Notarized)' },
|
||||
{ key: 'stampDutyReceipt', label: 'Legal Stamp Paper / Stamp Duty Receipt' },
|
||||
{ key: 'feeReceipt', label: 'Fee & Account Opening Receipt' }
|
||||
{ key: 'feeReceipt', label: 'Processing Fee Receipt' }
|
||||
];
|
||||
if (secured) {
|
||||
templates.push(
|
||||
@@ -1839,7 +2048,7 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
|
||||
const actionBy =
|
||||
input.channel === 'BC' ? input.bcAgentName : input.channel === 'SELF_APPLY' ? 'Customer (Digital)' : 'Branch Staff';
|
||||
return {
|
||||
applicationNo: `LN${input.branchCode.slice(2)}${input.productCode}${String(seq).padStart(6, '0')}`,
|
||||
applicationNo: `LN${input.branchCode.replace(/^(OFF|BR)/, '').padStart(2, '0')}${input.productCode}${String(seq).padStart(6, '0')}`,
|
||||
applicationDate: todayDDMMYYYY(),
|
||||
applicantName: input.applicantName,
|
||||
branchCode: input.branchCode,
|
||||
@@ -1932,7 +2141,7 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
|
||||
legalStampDone: false,
|
||||
fileFilingDone: false,
|
||||
agreementSigningSlot: '—',
|
||||
legalDocuments: buildLegalDocuments(input.secured),
|
||||
legalDocuments: buildLegalDocuments(input.secured, isDepositLoanCode(input.productCode)),
|
||||
customerSigningDate: '—',
|
||||
chequeReceived: false,
|
||||
achSigned: false,
|
||||
|
||||
@@ -865,6 +865,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
.loi-legal-doc-thumb-remove.loi-bank-remove {
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 999px;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
// CIC Credit Bureau Hero Card in Stage detail
|
||||
.loi-cic-hero-card {
|
||||
display: flex;
|
||||
@@ -978,3 +988,211 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.loi-optional-tag {
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.loi-deposit-note {
|
||||
margin-bottom: 14px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 8px;
|
||||
background: #f0f9ff;
|
||||
color: #075985;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.loi-dep-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.loi-dep-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
font-size: 13.5px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.r {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
tr.loi-dep-selected td {
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
tr.loi-dep-disabled td {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.loi-dep-calc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
margin: 12px 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.loi-dep-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 13.5px;
|
||||
color: #334155;
|
||||
|
||||
b {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
.loi-dep-total {
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed #cbd5e1;
|
||||
font-size: 15px;
|
||||
|
||||
b {
|
||||
color: #15803d;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.loi-dep-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
@media (width <= 700px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.loi-deposit-ok {
|
||||
margin: 6px 0;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 8px;
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.loi-lien-cert {
|
||||
margin: 12px 0;
|
||||
padding: 14px 18px;
|
||||
border: 2px dashed #94a3b8;
|
||||
border-radius: 10px;
|
||||
background: #fffef7;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.pg-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loi-approval-wait {
|
||||
margin: 10px 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 8px;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.loi-verified {
|
||||
margin: 8px 0 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
|
||||
ul {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li {
|
||||
padding: 2px 0;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.loi-verified--no {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.loi-locked {
|
||||
pointer-events: none;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.loi-checker {
|
||||
margin: 8px 0 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-radius: 8px;
|
||||
background: #eef2ff;
|
||||
font-size: 13px;
|
||||
color: #1e1b4b;
|
||||
}
|
||||
|
||||
/* A stage sent for approval: files can still be opened (View), nothing can be added, replaced or removed. */
|
||||
.loi-docs-locked {
|
||||
label.pg-btn,
|
||||
.loi-legal-doc-thumb-remove,
|
||||
.loi-doc-remove,
|
||||
.loi-custom-doc-input,
|
||||
.loan-action-row {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.loi-view-link {
|
||||
margin-left: 8px;
|
||||
padding: 2px 10px;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-radius: 999px;
|
||||
background: #eef2ff;
|
||||
color: #3730a3;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,13 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { LoanApplication } from './loan-origination-data';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { BranchMaster, LoanApplication } from './loan-origination-data';
|
||||
|
||||
/** The code a branch carries on an application: the Fineract office it belongs to (office 1 is "OFF1"). */
|
||||
export function officeBranchCode(officeId: number | string): string {
|
||||
return `OFF${officeId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistence for the Loan Origination Pipeline, in two deliberately different shapes.
|
||||
@@ -29,6 +35,23 @@ export class LoanOriginationService {
|
||||
|
||||
private readonly baseUrl = '/loan-origination/applications';
|
||||
|
||||
/** One fixed or recurring deposit account with its balance, rate and maturity. */
|
||||
getDepositAccount(kind: 'FD' | 'RD', accountId: number): Observable<any> {
|
||||
return this.http.get(`/${kind === 'FD' ? 'fixeddepositaccounts' : 'recurringdepositaccounts'}/${accountId}`);
|
||||
}
|
||||
|
||||
/** The bank's real branches: every office of the organisation, in hierarchy order. */
|
||||
listBranches(): Observable<BranchMaster[]> {
|
||||
return this.http.get<{ id: number; name: string; hierarchy?: string }[]>('/offices').pipe(
|
||||
map((offices) =>
|
||||
(offices ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => (a.hierarchy ?? '').localeCompare(b.hierarchy ?? '') || a.id - b.id)
|
||||
.map((office) => ({ code: officeBranchCode(office.id), name: office.name }))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
list(): Observable<LoanApplication[]> {
|
||||
return this.http.get<LoanApplication[]>(this.baseUrl);
|
||||
}
|
||||
@@ -67,6 +90,10 @@ export class LoanOriginationService {
|
||||
return this.http.post<LoanApplication>(`${this.baseUrl}/${encodeURIComponent(applicationNo)}/${path}`, body);
|
||||
}
|
||||
|
||||
completeDepositSelection(applicationNo: string, actionBy: string, remarks?: string): Observable<LoanApplication> {
|
||||
return this.stage(applicationNo, 'deposit-selection-complete', { actionBy, remarks });
|
||||
}
|
||||
|
||||
completeKyc(applicationNo: string, actionBy: string, remarks?: string): Observable<LoanApplication> {
|
||||
return this.stage(applicationNo, 'kyc-complete', { actionBy, remarks });
|
||||
}
|
||||
@@ -124,11 +151,31 @@ export class LoanOriginationService {
|
||||
remarks?: string;
|
||||
resolutionNo?: string;
|
||||
resolutionDate?: string;
|
||||
checkerChecklist?: { label: string; ok: boolean }[];
|
||||
}
|
||||
): Observable<LoanApplication> {
|
||||
return this.stage(applicationNo, `approvals/${field}`, body);
|
||||
}
|
||||
|
||||
submitApproval(
|
||||
applicationNo: string,
|
||||
field: string,
|
||||
body: {
|
||||
decision: 'APPROVED' | 'EXCEPTION_APPROVED';
|
||||
approverRole: string;
|
||||
remarks?: string;
|
||||
resolutionNo?: string;
|
||||
resolutionDate?: string;
|
||||
checklist?: { label: string; ok: boolean }[];
|
||||
}
|
||||
): Observable<LoanApplication> {
|
||||
return this.stage(applicationNo, `approvals/${field}/submit`, body);
|
||||
}
|
||||
|
||||
withdrawApproval(applicationNo: string, field: string): Observable<LoanApplication> {
|
||||
return this.stage(applicationNo, `approvals/${field}/withdraw`, {});
|
||||
}
|
||||
|
||||
markSigned(applicationNo: string, actionBy: string): Observable<LoanApplication> {
|
||||
return this.stage(applicationNo, 'signing', { actionBy });
|
||||
}
|
||||
|
||||
@@ -171,6 +171,51 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (awaitingApproval.length > 0) {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Awaiting Approval ({{ awaitingApproval.length }})</div>
|
||||
<div class="pg-table-scroll">
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Application No</th>
|
||||
<th>Applicant</th>
|
||||
<th>Stage</th>
|
||||
<th>Sent by</th>
|
||||
<th>Sent on</th>
|
||||
<th>For you</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of awaitingApproval; track row.app.applicationNo + row.stage) {
|
||||
<tr class="cctr-branch-row" (click)="openApplication(row.app)">
|
||||
<td>
|
||||
<span class="pg-chip">{{ row.app.applicationNo }}</span>
|
||||
</td>
|
||||
<td>{{ row.app.applicantName }}</td>
|
||||
<td>
|
||||
{{ row.stage }}
|
||||
@if (row.exception) {
|
||||
(exception)
|
||||
}
|
||||
</td>
|
||||
<td>{{ row.sentBy }}</td>
|
||||
<td>{{ row.sentOn }}</td>
|
||||
<td>
|
||||
@if (row.mine) {
|
||||
<span class="loan-badge loan-badge--hold">Sent by you — waiting for another user</span>
|
||||
} @else {
|
||||
<span class="loan-badge loan-badge--missing">Needs your decision</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Applications ({{ filteredApplications.length }})</div>
|
||||
<div class="pg-table-scroll">
|
||||
@@ -304,18 +349,37 @@
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Product</span>
|
||||
<select [(ngModel)]="newApp.productCode" (ngModelChange)="onProductChange()">
|
||||
@for (p of products; track p.code) {
|
||||
<option [value]="p.code">{{ p.name }}</option>
|
||||
<span>* Product Type</span>
|
||||
<select [(ngModel)]="newApp.productGroup" (ngModelChange)="onProductGroupChange()">
|
||||
<option value="">Select product type</option>
|
||||
@for (g of productGroups; track g.id) {
|
||||
<option [value]="g.id">{{ g.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Product Type</span>
|
||||
<select [(ngModel)]="newApp.productType">
|
||||
@for (t of newAppProductTypeOptions; track t) {
|
||||
<option [value]="t">{{ productTypeLabels[t] }}</option>
|
||||
<span>* Sub Type</span>
|
||||
<select
|
||||
[(ngModel)]="newApp.productSubType"
|
||||
(ngModelChange)="onSubTypeChange()"
|
||||
[disabled]="!newApp.productGroup"
|
||||
>
|
||||
<option value="">Select sub type</option>
|
||||
@for (t of subTypeOptions; track t.id) {
|
||||
<option [value]="t.id">{{ t.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Product</span>
|
||||
<select
|
||||
[(ngModel)]="newApp.productCode"
|
||||
(ngModelChange)="onProductChange()"
|
||||
[disabled]="!newApp.productSubType"
|
||||
>
|
||||
<option value="">{{ loadingProducts ? 'Loading products…' : 'Select product' }}</option>
|
||||
@for (p of productOptions; track p.id) {
|
||||
<option [value]="p.shortName">{{ p.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
@@ -325,7 +389,12 @@
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Tenure (Months)</span>
|
||||
<input type="number" [(ngModel)]="newApp.tenureMonths" placeholder="e.g. 36" />
|
||||
<select [(ngModel)]="newApp.tenureMonths" [disabled]="!selectedProduct">
|
||||
<option [ngValue]="null">{{ selectedProduct ? 'Select tenure' : 'Select a product first' }}</option>
|
||||
@for (m of tenureOptions; track m) {
|
||||
<option [ngValue]="m">{{ m }} months</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@
|
||||
@if (tenureBand; as band) {
|
||||
<mat-hint>
|
||||
{{ band.label }} policy: max {{ band.max }} months
|
||||
@if (band.label === 'FD') {
|
||||
@if (band.depositBacked) {
|
||||
(also capped at deposit maturity)
|
||||
}
|
||||
</mat-hint>
|
||||
|
||||
+17
-4
@@ -111,14 +111,27 @@ export class LoanProductAdvanceLimitTenureStepComponent {
|
||||
}
|
||||
|
||||
/** Bank-policy tenure band per sub type: FD 12 to 15 months, a bullet Gold loan 1 to 12. Others stay open. */
|
||||
private readonly tenureBands: Record<string, { label: string; min: number; max: number; defaultTenure: number }> = {
|
||||
fd: { label: 'FD', min: 12, max: 15, defaultTenure: 12 },
|
||||
private readonly tenureBands: Record<
|
||||
string,
|
||||
{ label: string; min: number; max: number; defaultTenure: number; depositBacked?: boolean }
|
||||
> = {
|
||||
// A loan against a deposit or policy is a short-term advance: 12 to 15 months for all four, and never past maturity.
|
||||
fd: { label: 'FD', min: 12, max: 15, defaultTenure: 12, depositBacked: true },
|
||||
rd: { label: 'RD', min: 12, max: 15, defaultTenure: 12, depositBacked: true },
|
||||
dds: { label: 'DDS', min: 12, max: 15, defaultTenure: 12, depositBacked: true },
|
||||
lic: { label: 'LIC', min: 12, max: 15, defaultTenure: 12, depositBacked: true },
|
||||
gold: { label: 'Gold', min: 1, max: 12, defaultTenure: 12 },
|
||||
warehouse: { label: 'Warehouse', min: 1, max: 12, defaultTenure: 6 }
|
||||
};
|
||||
|
||||
/** The policy band in force for the chosen sub type, or null when the sub type is open. */
|
||||
get tenureBand(): { label: string; min: number; max: number; defaultTenure: number } | null {
|
||||
get tenureBand(): {
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
defaultTenure: number;
|
||||
depositBacked?: boolean;
|
||||
} | null {
|
||||
return this.tenureBands[this._productSubType] ?? null;
|
||||
}
|
||||
|
||||
@@ -131,7 +144,7 @@ export class LoanProductAdvanceLimitTenureStepComponent {
|
||||
}
|
||||
|
||||
/**
|
||||
* FD (12–15 months) and Gold (1–12 months) are bank-policy capped; every other sub type stays open (min 1).
|
||||
* FD, RD, DDS and LIC (12–15 months), Gold and Warehouse (1–12 months) are bank-policy capped; every other sub type stays open (min 1).
|
||||
* Called whenever the sub type changes so the validators — and thus the wizard's validity gate —
|
||||
* match the selected product.
|
||||
*/
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '261013',
|
||||
'hash': 'd88f2a6eb'
|
||||
'version': '261022',
|
||||
'hash': 'ec550b179'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user