From 127c2a7d01545cb6ea8dba207aded39cc1d91500 Mon Sep 17 00:00:00 2001 From: Sonali Likhitkar Date: Fri, 16 Oct 2026 10:32:18 +0530 Subject: [PATCH] implemented the Edit product functionality for the RD,FD,DDS,LIC --- .../loans-account-details-step.component.html | 23 ++ ...loan-interest-rate-register.component.html | 9 + ...loan-interest-rate-register.component.scss | 28 ++ .../loan-interest-rate-register.component.ts | 19 + .../rate-register.service.ts | 7 + .../create-loan-product.component.html | 114 +++--- .../create-loan-product.component.scss | 24 ++ .../create-loan-product.component.ts | 378 +++++++++++++++++- .../loan-product-original.util.ts | 26 ++ .../loan-product-stepper/advance-steps.scss | 239 +++++++++++ ...oan-product-accounting-step.component.html | 338 +--------------- .../loan-product-accounting-step.component.ts | 133 +++++- ...t-advance-limit-tenure-step.component.html | 2 + ...an-product-cheque-book-step.component.html | 8 - ...loan-product-cheque-book-step.component.ts | 2 - .../loan-product-details-step.component.ts | 25 +- ...oduct-eligibility-docs-step.component.html | 60 +++ ...product-eligibility-docs-step.component.ts | 163 ++++++++ ...n-product-loan-repayment-step.component.ts | 6 +- ...an-product-loan-security-step.component.ts | 62 ++- ...loan-product-penal-npa-step.component.html | 90 ++++- .../loan-product-penal-npa-step.component.ts | 184 ++++++++- .../loan-product-preview-step.component.html | 42 +- .../loan-product-preview-step.component.scss | 71 ++++ .../loan-product-preview-step.component.ts | 246 +++++++++++- ...an-product-security-lien-step.component.ts | 18 +- .../register-rate-view.component.ts | 5 + .../loan-products.component.html | 152 +++++-- .../loan-products.component.scss | 15 + .../loan-products/loan-products.component.ts | 122 +++++- ...pending-product-view-dialog.component.html | 39 ++ ...pending-product-view-dialog.component.scss | 74 ++++ .../pending-product-view-dialog.component.ts | 78 ++++ .../create-range/create-range.component.html | 11 + .../create-range/create-range.component.ts | 7 +- .../edit-range/edit-range.component.html | 11 + .../edit-range/edit-range.component.ts | 7 +- .../view-range/view-range.component.html | 19 + .../manage-delinquency-buckets.component.html | 11 + .../prudential-floor.component.html | 64 +++ .../prudential-floor.component.resolver.ts | 32 ++ .../prudential-floor.component.scss | 19 + .../prudential-floor.component.ts | 118 ++++++ src/app/products/products-routing.module.ts | 20 + src/app/products/products.service.ts | 43 +- .../product-edit-diff-dialog.component.ts | 57 ++- src/app/tasks/command-diff.util.ts | 4 + src/app/tasks/maker-checker-entity-fetch.ts | 4 +- src/environments/.env.ts | 4 +- 49 files changed, 2694 insertions(+), 539 deletions(-) create mode 100644 src/app/products/loan-products/loan-product-original.util.ts create mode 100644 src/app/products/loan-products/loan-product-stepper/loan-product-eligibility-docs-step/loan-product-eligibility-docs-step.component.html create mode 100644 src/app/products/loan-products/loan-product-stepper/loan-product-eligibility-docs-step/loan-product-eligibility-docs-step.component.ts create mode 100644 src/app/products/loan-products/pending-product-view-dialog/pending-product-view-dialog.component.html create mode 100644 src/app/products/loan-products/pending-product-view-dialog/pending-product-view-dialog.component.scss create mode 100644 src/app/products/loan-products/pending-product-view-dialog/pending-product-view-dialog.component.ts create mode 100644 src/app/products/manage-delinquency-buckets/prudential-floor/prudential-floor.component.html create mode 100644 src/app/products/manage-delinquency-buckets/prudential-floor/prudential-floor.component.resolver.ts create mode 100644 src/app/products/manage-delinquency-buckets/prudential-floor/prudential-floor.component.scss create mode 100644 src/app/products/manage-delinquency-buckets/prudential-floor/prudential-floor.component.ts diff --git a/src/app/loans/loans-account-stepper/loans-account-details-step/loans-account-details-step.component.html b/src/app/loans/loans-account-stepper/loans-account-details-step/loans-account-details-step.component.html index 8d89046f1..6d2bfe44a 100644 --- a/src/app/loans/loans-account-stepper/loans-account-details-step/loans-account-details-step.component.html +++ b/src/app/loans/loans-account-stepper/loans-account-details-step/loans-account-details-step.component.html @@ -7,6 +7,27 @@ -->
+
+ + Category + + All categories + @for (category of categories; track category.id) { + {{ category.label }} + } + + + + Product type + + All types + @for (type of typeOptions; track type.id) { + {{ type.label }} + } + + +
+
{{ 'labels.inputs.Product Name' | translate }} @@ -35,6 +56,8 @@
+ + @if (loanProductSelected) {
diff --git a/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html index cf9e44f33..1178d9c05 100644 --- a/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html +++ b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html @@ -96,6 +96,15 @@ > Edit · Submit +
- + INTEREST @@ -82,19 +84,7 @@ - - - - PENAL & NPA - -
- -
-
- - + RENEWAL & CLOSURE @@ -105,7 +95,7 @@ @if (isOverdraftFacility) { - + CHEQUE BOOK @@ -118,7 +108,7 @@ } @if (isLoanAccount) { - + SECURITY & COLLATERAL @@ -128,7 +118,7 @@ - + AMOUNT & TENURE @@ -138,7 +128,7 @@ - + REPAYMENT & EMI @@ -148,7 +138,7 @@ - + INTEREST @@ -161,19 +151,7 @@ - - - - PENAL & NPA - -
- -
-
- - + PREPAYMENT & CLOSURE @@ -184,12 +162,27 @@ } + + + + + PENAL & NPA + +
+ +
+
+ - + {{ 'labels.inputs.CHARGES' | translate }} @@ -270,7 +263,7 @@ - + {{ 'labels.inputs.ACCOUNTING' | translate }} @@ -302,12 +295,15 @@ [accountingRuleData]="accountingRuleData" [loanProductFormValid]="loanProductFormValid" [deferredIncomeRecognition]="deferredIncomeRecognition" + [productTypeGroup]="loanProductDetailsForm.get('productTypeGroup')?.value" + [productTypeSubGroup]="loanProductDetailsForm.get('productTypeSubGroup')?.value" + [productGlAccountId]="productGlAccount" > @if (loanProductFormValid) { - + {{ 'labels.inputs.PREVIEW' | translate }} @@ -316,7 +312,7 @@ @@ -324,6 +320,25 @@ } + @if (hasProductType && !loanProductFormValid) { +
+ +
+ The Review tab appears once every tab is complete. + Still to finish: + @for (item of incompleteTabs; track item.tab; let last = $last) { + {{ item.tab }} + @if (item.fields.length > 0) { + ({{ item.fields.join(', ') }}) + } + @if (!last) { + , + } + } +
+
+ } +
} @if (selectedTabIndex < totalTabs - 1) { - @@ -344,7 +364,7 @@ @if (loanProductFormValid && selectedTabIndex === totalTabs - 1) { }
diff --git a/src/app/products/loan-products/create-loan-product/create-loan-product.component.scss b/src/app/products/loan-products/create-loan-product/create-loan-product.component.scss index 68e4c9ac5..d86362fa8 100644 --- a/src/app/products/loan-products/create-loan-product/create-loan-product.component.scss +++ b/src/app/products/loan-products/create-loan-product/create-loan-product.component.scss @@ -90,3 +90,27 @@ $primary: #1353a0; gap: 12px; } } + +/* Tells the user which tabs still block the Review tab. */ +.lp-incomplete { + display: flex; + align-items: flex-start; + gap: 10px; + margin: 16px 0 0; + padding: 10px 14px; + border: 1px solid #fcd34d; + border-radius: 8px; + background: #fffbeb; + color: #78350f; + font-size: 13px; + line-height: 1.5; + + i { + margin-top: 3px; + color: #d97706; + } + + b { + color: #92400e; + } +} diff --git a/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts b/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts index 005e57c45..2afcf2f75 100644 --- a/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts +++ b/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts @@ -7,7 +7,7 @@ */ /** Angular Imports */ -import { Component, OnInit, ViewChild, inject } from '@angular/core'; +import { AfterViewInit, Component, OnInit, ViewChild, inject } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; /** Custom Components */ @@ -84,7 +84,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; LoanProductPreviewStepComponent ] }) -export class CreateLoanProductComponent implements OnInit { +export class CreateLoanProductComponent implements OnInit, AfterViewInit { private route = inject(ActivatedRoute); private productsService = inject(ProductsService); private alertService = inject(AlertService); @@ -135,6 +135,8 @@ export class CreateLoanProductComponent implements OnInit { loanProductAccountingStep: LoanProductAccountingStepComponent; loanProductsTemplate: any; + /** True when this wizard edits a saved product (the edit route) instead of creating one. */ + isEdit = false; accountingRuleData: string[] = []; itemsByDefault: any[] = []; selectedTabIndex = 0; @@ -158,22 +160,194 @@ export class CreateLoanProductComponent implements OnInit { constructor() { const loanProducts = this.loanProducts; - this.route.data.subscribe((data: { loanProductsTemplate: any; configurations: any }) => { - this.loanProductsTemplate = data.loanProductsTemplate; - const assetAccountData = this.loanProductsTemplate.accountingMappingOptions.assetAccountOptions || []; - const liabilityAccountData = this.loanProductsTemplate.accountingMappingOptions.liabilityAccountOptions || []; - this.loanProductsTemplate.accountingMappingOptions.assetAndLiabilityAccountOptions = - assetAccountData.concat(liabilityAccountData); + this.route.data.subscribe( + (data: { loanProductsTemplate: any; loanProductAndTemplate: any; configurations: any }) => { + // The edit route resolves the saved product together with the template options. + this.isEdit = !!data.loanProductAndTemplate; + this.loanProductsTemplate = data.loanProductAndTemplate ?? data.loanProductsTemplate; + const assetAccountData = this.loanProductsTemplate.accountingMappingOptions.assetAccountOptions || []; + const liabilityAccountData = this.loanProductsTemplate.accountingMappingOptions.liabilityAccountOptions || []; + this.loanProductsTemplate.accountingMappingOptions.assetAndLiabilityAccountOptions = + assetAccountData.concat(liabilityAccountData); - this.itemsByDefault = loanProducts.setItemsByDefault(data.configurations); - this.loanProductsTemplate['itemsByDefault'] = this.itemsByDefault; - this.loanProductsTemplate = loanProducts.updateLoanProductDefaults(this.loanProductsTemplate, false); - }); + this.itemsByDefault = loanProducts.setItemsByDefault(data.configurations); + this.loanProductsTemplate['itemsByDefault'] = this.itemsByDefault; + if (!this.isEdit) { + this.loanProductsTemplate = loanProducts.updateLoanProductDefaults(this.loanProductsTemplate, false); + } + } + ); } ngOnInit() { this.accountingRuleData = this.accounting.getAccountingRulesForLoans(); this.buildAdvancedPaymentAllocation(); + // Only Advances products are filled into the curated tabs so far. A product made before the type was saved, or a + // Loans A/C (EMI) product, uses the old editor instead of an empty form. + if (this.isEdit && this.loanProductsTemplate?.advanceExtensions?.productTypeGroup !== 'advances') { + this.router.navigate(['../edit-legacy'], { relativeTo: this.route }); + } + } + + ngAfterViewInit(): void { + if (this.isEdit) { + this.populateWhenReady(0); + } + } + + /** The tabs of the product's type appear after the Details step sets the type; fill them once they exist. */ + private populateWhenReady(attempt: number): void { + const group = this.loanProductsTemplate?.advanceExtensions?.productTypeGroup; + const ready = + group === 'advances' + ? !!( + this.loanProductSecurityLienStep && + this.loanProductAdvanceLimitTenureStep && + this.loanProductAdvanceInterestStep && + this.loanProductRenewalClosureStep && + this.loanProductPenalNpaStep && + (!this.isOverdraftFacility || this.loanProductChequeBookStep) + ) + : false; + if (!ready) { + if (attempt < 60) { + setTimeout(() => this.populateWhenReady(attempt + 1), 150); + } + return; + } + this.populateAdvance(); + } + + /** Sets the controls and marks them touched-by-user so a sub type default does not overwrite the saved value. */ + private fill(form: UntypedFormGroup | undefined, values: Record): void { + if (!form) { + return; + } + Object.keys(values).forEach((name) => { + const control = form.get(name); + const value = values[name]; + if (control && value !== undefined && value !== null) { + // A YES / NO select is saved as true / false; give it the word the select understands. + const isYesNoSelect = control.value === 'YES' || control.value === 'NO'; + control.setValue(isYesNoSelect && typeof value === 'boolean' ? (value ? 'YES' : 'NO') : value); + control.markAsDirty(); + } + }); + } + + /** Reverse of assembleAdvanceProduct: the saved core fields and the extension record go back into the tabs. */ + private populateAdvance(): void { + const p = this.loanProductsTemplate; + const e = p.advanceExtensions ?? {}; + const split = (value: any) => + value + ? String(value) + .split('|') + .filter((v) => v !== '') + : undefined; + + this.fill(this.loanProductCurrencyStep?.loanProductCurrencyForm, { + currencyCode: p.currency?.code, + digitsAfterDecimal: p.currency?.decimalPlaces + }); + + this.fill(this.loanProductSecurityLienStep.loanProductSecurityLienForm, { + securityType: e.securityType, + eligibleAmountBasis: e.eligibleAmountBasis, + margin: e.margin, + lienMarkingStage: e.lienMarkingStage, + eligibleDepositProducts: split(e.eligibleDepositProducts), + tenureNotExceedMaturity: e.lienTenureNotExceedMaturity, + autoReleaseOnClosure: e.lienAutoReleaseOnClosure, + allowMultipleDeposits: e.lienAllowMultipleDeposits, + adjustFromMaturity: e.lienAdjustFromMaturity, + allowThirdPartyDeposit: e.lienAllowThirdParty, + allowRenewalUnderLien: e.lienAllowRenewalUnderLien, + lienCertificateRequired: e.lienCertificateRequired, + lienCertificatePrefix: e.lienCertificatePrefix, + printOnDisbursement: e.printCertificateOnDisbursement + }); + + this.fill(this.loanProductAdvanceLimitTenureStep.loanProductAdvanceLimitTenureForm, { + facilityType: e.facilityType, + minLimit: p.minPrincipal, + defaultLimit: p.principal, + maxLimit: p.maxPrincipal, + minTenure: p.minNumberOfRepayments, + defaultTenure: p.numberOfRepayments, + maxTenure: p.maxNumberOfRepayments, + repaymentType: e.repaymentType, + appropriationOrder: e.appropriationOrder, + debitBalanceAllow: e.debitBalanceAllow, + allowDebitTransAfterDueDate: e.allowDebitAfterDueDate, + branchLevelAccountType: e.branchLevelAccountType + }); + + this.fill(this.loanProductAdvanceInterestStep.loanProductAdvanceInterestForm, { + interestBasis: e.interestBasis, + registerParticular: e.registerParticular, + spread: e.spread, + calcMethod: e.interestCalcMethod, + interestChargedOn: e.interestChargedOn, + interestOnCreditBalance: e.interestOnCreditBalance, + postingFrequency: e.interestPostingFrequency, + postingDay: e.interestPostingDay + }); + + this.fill(this.loanProductRenewalClosureStep.loanProductRenewalClosureForm, { + renewalAllowed: e.renewalAllowed, + renewalPeriodMonths: e.renewalPeriodMonths, + renewalGraceDays: e.renewalGraceDays, + preclosureAllowed: e.preclosureAllowed, + lockInMonths: e.lockInMonths, + interestOnPreclosure: e.interestOnPreclosure + }); + + this.fill(this.loanProductPenalNpaStep.loanProductPenalNpaForm, { + penalApplicable: e.penalApplicable, + penalRate: e.penalRate, + penalAppliedOn: e.penalAppliedOn, + penalGraceDays: e.penalGraceDays, + delinquencyBucketId: p.delinquencyBucket?.id, + npaDays: p.overdueDaysForNPA, + npaTrigger: e.npaTrigger, + interestAfterNpa: e.interestAfterNpa, + smaStartAfterMonths: e.smaStartAfterMonths, + stockStatementValidityDays: e.stockStatementValidityDays, + inArrearsTolerance: p.inArrearsTolerance, + upgradeOnlyAllArrears: e.npaUpgradeOnlyAllArrears, + keepStandardIfDepositCovers: e.npaKeepStandardIfDepositCovers, + npaOutOfOrderBasis: e.npaOutOfOrderBasis, + cropDuration: e.cropDuration + }); + + if (this.isOverdraftFacility && this.loanProductChequeBookStep) { + this.fill(this.loanProductChequeBookStep.loanProductChequeBookForm, { + chequeType: e.chequeType, + bookSizes: split(e.chequeBookSizes), + defaultBookSize: e.defaultChequeBookSize !== undefined ? String(e.defaultChequeBookSize) : undefined, + maxBooksOutstanding: e.chequeMaxBooksOutstanding, + firstBookFree: e.chequeFirstBookFree, + freeBooksPerYear: e.chequeFreeBooksPerYear, + namePrinted: e.chequeNamePrinted, + ppsThreshold: e.ppsThreshold, + ppsMandatoryAbove: e.ppsMandatoryAbove, + stopPaymentAllowed: e.chequeStopPaymentAllowed, + maintainStockRegister: e.chequeMaintainStockRegister, + allowWithdrawalSlip: e.chequeAllowWithdrawalSlip, + blockNpa: e.chequeBlockNpa, + blockLimitExpired: e.chequeBlockLimitExpired, + blockOverDp: e.chequeBlockOverDp, + blockFrozen: e.chequeBlockFrozen, + blockLienReleased: e.chequeBlockLienReleased + }); + } + + // The nine GLs: the core ones are filled by the Accounting step from the saved mappings; the suspense GL is + // kept on the extension record. + this.fill(this.loanProductAccountingStep?.loanProductAccountingForm, { + undebitedInterestAccountId: e.undebitedInterestGlId + }); } get loanProductDetailsForm() { @@ -305,6 +479,12 @@ export class CreateLoanProductComponent implements OnInit { return this.loanProductAccountingStep.loanProductAccountingForm; } + /** True once the Details step has both a product type and a sub type; every other tab stays locked until then. */ + get hasProductType(): boolean { + const details = this.loanProductDetailsForm; + return !!details?.get('productTypeGroup')?.value && !!details?.get('productTypeSubGroup')?.value; + } + /** The Details step's chosen product type; drives the Advances-only tabs. */ get isAdvanceProduct(): boolean { return this.loanProductDetailsForm?.get('productTypeGroup')?.value === 'advances'; @@ -316,14 +496,14 @@ export class CreateLoanProductComponent implements OnInit { } get totalTabs(): number { - let tabs = 4; // details, currency, charges, accounting (always) + let tabs = 5; // details, currency, penal & npa, charges, accounting (always) if (this.isAdvanceProduct) { - // security & lien, limit & tenure, interest, penal & npa, renewal & closure - tabs += 5; + // security & lien, limit & tenure, interest, renewal & closure + tabs += 4; if (this.isOverdraftFacility) tabs += 1; // cheque book } else if (this.isLoanAccount) { - // security & collateral, amount & tenure, repayment & emi, interest, penal & npa, prepayment & closure - tabs += 6; + // security & collateral, amount & tenure, repayment & emi, interest, prepayment & closure + tabs += 5; } // Old Loans A/C path (Settings, Terms, Interest Refund, Payment Allocation, Deferred Income) — commented out: // else { @@ -334,9 +514,114 @@ export class CreateLoanProductComponent implements OnInit { return tabs; } + /** + * The tabs that still block the Review tab, each with the fields to fix. The Review tab only appears once every + * tab is valid, and a tab that was never opened or a missing field is otherwise invisible to the user. + */ + get incompleteTabs(): { tab: string; fields: string[] }[] { + const humanize = (name: string) => + name + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/Id$/, '') + .replace(/^./, (ch) => ch.toUpperCase()); + const tabs: [string, any][] = [ + [ + 'Details', + this.loanProductDetailsForm + ], + [ + 'Currency', + this.loanProductCurrencyForm + ] + ]; + if (this.isAdvanceProduct) { + tabs.push( + [ + 'Security & Lien', + this.loanProductSecurityLienStep?.loanProductSecurityLienForm + ], + [ + 'Limit & Tenure', + this.loanProductAdvanceLimitTenureStep?.loanProductAdvanceLimitTenureForm + ], + [ + 'Interest', + this.loanProductAdvanceInterestStep?.loanProductAdvanceInterestForm + ], + [ + 'Renewal & Closure', + this.loanProductRenewalClosureStep?.loanProductRenewalClosureForm + ] + ); + if (this.isOverdraftFacility) { + tabs.push([ + 'Cheque Book', + this.loanProductChequeBookStep?.loanProductChequeBookForm + ]); + } + } else if (this.isLoanAccount) { + tabs.push( + [ + 'Security & Collateral', + this.loanProductLoanSecurityStep?.loanProductLoanSecurityForm + ], + [ + 'Amount & Tenure', + this.loanProductLoanAmountTenureStep?.loanProductLoanAmountTenureForm + ], + [ + 'Repayment & EMI', + this.loanProductLoanRepaymentStep?.loanProductLoanRepaymentForm + ], + [ + 'Interest', + this.loanProductLoanInterestStep?.loanProductLoanInterestForm + ], + [ + 'Prepayment & Closure', + this.loanProductLoanPrepaymentStep?.loanProductLoanPrepaymentForm + ] + ); + } + tabs.push( + [ + 'Penal & NPA', + this.loanProductPenalNpaStep?.loanProductPenalNpaForm + ], + [ + 'Accounting', + this.loanProductAccountingStep?.loanProductAccountingForm + ] + ); + return tabs + .filter( + ([ + , + form + ]) => !form || form.invalid + ) + .map( + ([ + tab, + form + ]) => ({ + tab, + fields: form + ? Object.keys(form.controls) + .filter((key) => form.controls[key].invalid) + .slice(0, 4) + .map(humanize) + : [] + }) + ); + } + get loanProductFormValid() { const base = - this.loanProductDetailsForm.valid && this.loanProductCurrencyForm.valid && this.loanProductAccountingForm.valid; + this.loanProductDetailsForm.valid && + this.loanProductCurrencyForm.valid && + this.loanProductAccountingForm.valid && + !!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid; // Advances A/C: the curated Figma steps gate the form; Settings/Terms tabs are hidden. if (this.isAdvanceProduct) { @@ -345,7 +630,6 @@ export class CreateLoanProductComponent implements OnInit { !!this.loanProductSecurityLienStep?.loanProductSecurityLienForm?.valid && !!this.loanProductAdvanceLimitTenureStep?.loanProductAdvanceLimitTenureForm?.valid && !!this.loanProductAdvanceInterestStep?.loanProductAdvanceInterestForm?.valid && - !!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid && !!this.loanProductRenewalClosureStep?.loanProductRenewalClosureForm?.valid && (!this.isOverdraftFacility || !!this.loanProductChequeBookStep?.loanProductChequeBookForm?.valid) ); @@ -359,7 +643,6 @@ export class CreateLoanProductComponent implements OnInit { !!this.loanProductLoanAmountTenureStep?.loanProductLoanAmountTenureForm?.valid && !!this.loanProductLoanRepaymentStep?.loanProductLoanRepaymentForm?.valid && !!this.loanProductLoanInterestStep?.loanProductLoanInterestForm?.valid && - !!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid && !!this.loanProductLoanPrepaymentStep?.loanProductLoanPrepaymentForm?.valid ); } @@ -375,6 +658,33 @@ export class CreateLoanProductComponent implements OnInit { // return base && loansValid; } + private previewKey = ''; + private previewProduct: any = {}; + + /** + * The product the Review tab shows. It is rebuilt only when a form value changes, so the summary gets a stable object + * (it fills in currency, schedule type and so on on the object it receives). Submit still uses the fresh `loanProduct`. + */ + get previewLoanProduct(): any { + const key = JSON.stringify( + Object.keys(this) + .filter((name) => /Step$/.test(name)) + .map((name) => { + const step = (this as any)[name]; + return step + ? Object.keys(step) + .filter((prop) => /Form$/.test(prop)) + .map((prop) => step[prop]?.getRawValue?.()) + : null; + }) + ); + if (key !== this.previewKey) { + this.previewKey = key; + this.previewProduct = this.loanProduct; + } + return this.previewProduct; + } + get loanProduct() { // Advances A/C builds its own payload: the curated steps map onto Fineract's mandatory core fields // (principal, tenure, rate, amortization defaults) plus the whitelisted satellite params. @@ -535,9 +845,15 @@ export class CreateLoanProductComponent implements OnInit { penalAppliedOn: pn.penalAppliedOn, penalGraceDays: num(pn.penalGraceDays), npaTrigger: pn.npaTrigger, + smaStartAfterMonths: num(pn.smaStartAfterMonths), interestAfterNpa: pn.interestAfterNpa, npaUpgradeOnlyAllArrears: pn.upgradeOnlyAllArrears, npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers, + + // The SICR & LGD (ECL) card is hidden for now, so its values are not sent. + npaOutOfOrderBasis: pn.npaOutOfOrderBasis || undefined, + cropDuration: pn.cropDuration || undefined, + securityMandatory: sec.securityMandatory, guarantorsRequired: num(sec.guarantorsRequired), coApplicant: sec.coApplicant, @@ -702,7 +1018,14 @@ export class CreateLoanProductComponent implements OnInit { penalAppliedOn: pn.penalAppliedOn, penalGraceDays: num(pn.penalGraceDays), npaTrigger: pn.npaTrigger, + smaStartAfterMonths: num(pn.smaStartAfterMonths), + stockStatementValidityDays: num(pn.stockStatementValidityDays), interestAfterNpa: pn.interestAfterNpa, + + // The SICR & LGD (ECL) card is hidden for now, so its values are not sent. + npaOutOfOrderBasis: pn.npaOutOfOrderBasis || undefined, + cropDuration: pn.cropDuration || undefined, + renewalAllowed: rc.renewalAllowed, renewalPeriodMonths: num(rc.renewalPeriodMonths), renewalGraceDays: num(rc.renewalGraceDays), @@ -740,11 +1063,9 @@ export class CreateLoanProductComponent implements OnInit { // cheque book full config chequeBookSizes: (cb?.bookSizes ?? []).join('|'), chequeMaxBooksOutstanding: num(cb?.maxBooksOutstanding), - chequeReissueWhenLeaves: num(cb?.reissueWhenLeaves), chequeFirstBookFree: cb?.firstBookFree, chequeFreeBooksPerYear: num(cb?.freeBooksPerYear), chequeNamePrinted: cb?.namePrinted, - chequePrintDescription: cb?.chequePrintDescription, chequeStopPaymentAllowed: cb?.stopPaymentAllowed, chequeMaintainStockRegister: cb?.maintainStockRegister, chequeAllowWithdrawalSlip: cb?.allowWithdrawalSlip, @@ -771,6 +1092,19 @@ export class CreateLoanProductComponent implements OnInit { delete loanProduct['daysInYearCustomStrategy']; } + if (this.isEdit) { + this.productsService.updateLoanProduct(this.loanProductsTemplate.id, loanProduct).subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Loan product update request submitted successfully for approval.' + }); + } + this.router.navigate(['../'], { relativeTo: this.route }); + }); + return; + } + this.productsService.createLoanProduct(loanProduct).subscribe({ next: (response: any) => { if (response?.rollbackTransaction) { diff --git a/src/app/products/loan-products/loan-product-original.util.ts b/src/app/products/loan-products/loan-product-original.util.ts new file mode 100644 index 000000000..eacb920b1 --- /dev/null +++ b/src/app/products/loan-products/loan-product-original.util.ts @@ -0,0 +1,26 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * A saved Advances / Loans product keeps most of its settings in an extension record (`advanceExtensions`), under the + * same names the edit form submits. Putting those next to the core fields gives every submitted field a current value + * to compare against, so a pending edit shows only what really changed. Core fields win on a name clash. + * + * The product read leaves out a core number that is zero, while the edit form always sends it, so that one is filled in + * as 0 rather than reading as "newly set" on every edit. + */ +export function withExtensions(product: any): any { + if (!product) { + return product; + } + const merged = product.advanceExtensions ? { ...product.advanceExtensions, ...product } : { ...product }; + if (merged.inArrearsTolerance === undefined || merged.inArrearsTolerance === null) { + merged.inArrearsTolerance = 0; + } + return merged; +} diff --git a/src/app/products/loan-products/loan-product-stepper/advance-steps.scss b/src/app/products/loan-products/loan-product-stepper/advance-steps.scss index 062f2f8fe..03dd72683 100644 --- a/src/app/products/loan-products/loan-product-stepper/advance-steps.scss +++ b/src/app/products/loan-products/loan-product-stepper/advance-steps.scss @@ -214,3 +214,242 @@ $gray-900: #202124; grid-template-columns: 1fr; } } + +/* Penal / SMA / NPA / ECL cards: each gets its own accent so they read as separate blocks. */ +.pn-card { + border-left-width: 4px; + + &--penal { + border-left-color: #0ea5e9; + } + + &--sma { + border-left-color: #f59e0b; + } + + &--npa { + border-left-color: #dc2626; + } + + &--ecl { + border-left-color: #6366f1; + } +} + +.pn-stages { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 0 16px 16px; +} + +.pn-stage { + padding: 4px 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + + &--0 { + background: #fef9c3; + color: #854d0e; + } + + &--1 { + background: #ffedd5; + color: #9a3412; + } + + &--2 { + background: #fee2e2; + color: #991b1b; + } +} + +/* ECL card: collapsed by default, grouped, plain wording. */ +.pn-ecl-head { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 14px 16px; + border: 0; + background: #f8fafc; + color: #1e293b; + font: inherit; + text-align: left; + cursor: pointer; + + &:hover { + background: #f1f5f9; + } + + &__title { + display: inline-flex; + align-items: center; + gap: 10px; + white-space: nowrap; + + i { + color: #6366f1; + } + } + + &__tag { + padding: 2px 8px; + border-radius: 999px; + background: #e0e7ff; + color: #3730a3; + font-size: 11px; + font-weight: 600; + } + + &__summary { + flex: 1; + min-width: 0; + overflow: hidden; + color: #64748b; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.pn-ecl-body { + padding: 4px 16px 16px; +} + +.pn-ecl-intro { + margin: 8px 0 16px; + color: #475569; + font-size: 13px; + line-height: 1.5; +} + +.pn-ecl-group { + margin-bottom: 20px; + + h4 { + margin: 0 0 12px; + color: #1e293b; + font-size: 14px; + font-weight: 600; + } +} + +.pn-ecl-row { + margin-top: 12px; +} + +.pn-ecl-check { + display: flex; + align-items: center; + min-height: 40px; +} + +/* Eligibility & Documents tab. */ +.ed-body { + padding: 16px; +} + +.ed-count { + margin-left: auto; + padding: 2px 10px; + border-radius: 999px; + background: #e0f2fe; + color: #075985; + font-size: 12px; + font-weight: 600; +} + +.ed-rules { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + + @media (width <= 900px) { + grid-template-columns: 1fr; + } +} + +.ed-rule { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border: 1px solid #e2e8f0; + border-radius: 8px; + cursor: pointer; + + &__text { + display: flex; + flex-direction: column; + gap: 2px; + + strong { + color: #1e293b; + font-size: 14px; + font-weight: 600; + } + + small { + color: #64748b; + font-size: 12px; + } + } +} + +.ed-group { + margin-bottom: 18px; + + &:last-child { + margin-bottom: 0; + } + + &__title { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + color: #475569; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + + i { + color: #6366f1; + } + } +} + +.ed-docs { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 12px; + + @media (width <= 900px) { + grid-template-columns: 1fr; + } +} + +.ed-doc { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 12px 4px 4px; + border: 1px solid #e2e8f0; + border-radius: 8px; + color: #334155; + font-size: 14px; + cursor: pointer; + + &:hover { + background: #f8fafc; + } + + &--on { + border-color: #a5b4fc; + background: #eef2ff; + color: #1e1b4b; + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-accounting-step/loan-product-accounting-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-accounting-step/loan-product-accounting-step.component.html index d01180c8a..ffefcb508 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-accounting-step/loan-product-accounting-step.component.html +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-accounting-step/loan-product-accounting-step.component.html @@ -38,18 +38,12 @@

{{ section.title }}

-
@for (row of section.rows; track row.key) { @if ( - (!row.accrualOnly || isAccrualAccounting()) && + basicControls.has(row.controlName) && + (!row.accrualOnly || isAccrualAccounting() || basicControls.has(row.controlName)) && (!row.deferredOnly || isDeferredOnly()) && (!row.deferredCapitalizationOnly || isDeferredCapitalizationOnly()) && (!row.deferredBuyDownOnly || isDeferredBuyDownOnly()) && @@ -78,334 +72,6 @@ } }
- - -
- @for ( - otherRow of getOtherGlMappingsArray(section.otherMappingsControlName).controls; - track $index; - let i = $index - ) { -
-
-
- - {{ section.pillLabel }} - - -
- -
- - GL Code - - @for (glAccount of section.otherAccountOptions; track glAccount.id) { - - {{ '(' + glAccount.glCode + ') ' + glAccount.name }} - - } - - -
-
- -
- } -
-
- } - - - @if (loanProductAccountingForm.value.accountingRule >= 2 && loanProductAccountingForm.value.accountingRule <= 4) { -
-
-

Advanced Accounting Rules

-
-
- {{ - 'labels.heading.Advanced Accounting Rules' | translate - }} -
- - @if (loanProductAccountingForm.value.advancedAccountingRules) { - -
-
-

{{ 'labels.heading.Configure Fund Sources for Payment Channels' | translate }}

- -
- @if (paymentChannelToFundSourceMappings.value.length !== 0) { -
- - - - - - - - - - - - - - - -
{{ 'labels.inputs.Payment Type' | translate }} - {{ paymentFundSource.paymentTypeId | find: paymentTypeData : 'id' : 'name' }} - {{ 'labels.inputs.Fund Source' | translate }} - {{ paymentFundSource.fundSourceAccountId | find: assetAccountData : 'id' : 'name' }} - {{ 'labels.inputs.Actions' | translate }} - - -
-
- } -
- - -
-
-

{{ 'labels.heading.Map Fees to Specific Income Accounts' | translate }}

- -
- @if (feeToIncomeAccountMappings.value.length !== 0) { -
- - - - - - - - - - - - - - - -
{{ 'labels.inputs.Fees' | translate }} - {{ feesIncome.chargeId | find: chargeData : 'id' : 'name' }} - {{ 'labels.inputs.Income Account' | translate }} - {{ feesIncome.incomeAccountId | find: incomeAndLiabilityAccountData : 'id' : 'name' }} - {{ 'labels.inputs.Actions' | translate }} - - -
-
- } -
- - -
-
-

{{ 'labels.heading.Map Penalties to Specific Income Accounts' | translate }}

- -
- @if (penaltyToIncomeAccountMappings.value.length !== 0) { -
- - - - - - - - - - - - - - - -
{{ 'labels.inputs.Penalty' | translate }} - {{ penaltyIncome.chargeId | find: penaltyData : 'id' : 'name' }} - {{ 'labels.inputs.Income Account' | translate }} - {{ penaltyIncome.incomeAccountId | find: incomeAccountData : 'id' : 'name' }} - {{ 'labels.inputs.Actions' | translate }} - - -
-
- } -
- - -
-
-

{{ 'labels.heading.Map Charge-off reasons to Expense accounts' | translate }}

- -
- @if (chargeOffReasonToExpenseAccountMappings.value.length !== 0) { -
- - - - - - - - - - - - - - - -
{{ 'labels.inputs.Charge-off reason' | translate }} - {{ - chargeOffReasonExpense.chargeOffReasonCodeValueId - | find: chargeOffReasonOptions : 'id' : 'name' - }} - {{ 'labels.inputs.Expense Account' | translate }} - {{ chargeOffReasonExpense.expenseAccountId | find: expenseAccountData : 'id' : 'name' }} - {{ 'labels.inputs.Actions' | translate }} - - -
-
- } -
- - - - - - }
} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-accounting-step/loan-product-accounting-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-accounting-step/loan-product-accounting-step.component.ts index 4b3880fd6..dc7aed938 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-accounting-step/loan-product-accounting-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-accounting-step/loan-product-accounting-step.component.ts @@ -104,6 +104,23 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges { @Input() accountingRuleData: any; @Input() loanProductFormValid: boolean; @Input() deferredIncomeRecognition: DeferredIncomeRecognition; + /** The product's category / type and its portfolio GL, from the Details step; they drive the default GL mapping. */ + @Input() productTypeGroup: string | null = null; + @Input() productTypeSubGroup: string | null = null; + @Input() productGlAccountId: number | string | null = null; + + /** The mappings every product needs; the rest default from these and sit behind "Show all mappings". */ + readonly basicControls = new Set([ + 'loanPortfolioAccountId', + 'fundSourceAccountId', + 'interestOnLoanAccountId', + 'incomeFromFeeAccountId', + 'incomeFromPenaltyAccountId', + 'writeOffAccountId', + 'receivableInterestAccountId', + 'overpaymentLiabilityAccountId', + 'undebitedInterestAccountId' + ]); public loanProductAccountingForm!: UntypedFormGroup; @@ -151,6 +168,73 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges { if (this.loanProductAccountingForm) { this.setDeferredIncomeRecognitionControls(); } + if (changes['productTypeGroup'] || changes['productTypeSubGroup'] || changes['productGlAccountId']) { + this.applyDefaultMapping(); + } + } + + /** The id of the GL account with this GL code among all the accounting options, or null. */ + private glIdByCode(code: string): number | null { + const all = [ + ...(this.assetAccountData ?? []), + ...(this.incomeAccountData ?? []), + ...(this.expenseAccountData ?? []), + ...(this.liabilityAccountData ?? []) + ]; + return all.find((account: any) => String(account.glCode) === code)?.id ?? null; + } + + /** + * Pre-fills the GL mappings from the product type: the portfolio GL is the product's own GL, interest income follows + * the loan term (Advances short, Loans medium, Housing long), the rest are the bank's shared GLs. A mapping the + * officer has already changed is left alone. + */ + applyDefaultMapping(): void { + // A saved product keeps its own mappings; one saved without any still gets the defaults. + const hasSavedMappings = !!this.loanProductsTemplate?.id && !!this.loanProductsTemplate?.accountingMappings; + if (!this.loanProductAccountingForm || !this.assetAccountData || hasSavedMappings) { + return; + } + const isAdvance = this.productTypeGroup === 'advances'; + const interestCode = isAdvance ? '400301' : this.productTypeSubGroup === 'housing' ? '400303' : '400302'; + const cash = this.glIdByCode('100101'); + const interest = this.glIdByCode(interestCode); + const fee = this.glIdByCode('400106') ?? this.glIdByCode('400101'); + const penal = this.glIdByCode('400304') ?? this.glIdByCode('400101'); + const writeOff = this.glIdByCode('500503'); + const receivable = this.glIdByCode('100601'); + const overpayment = this.glIdByCode('200907'); + const suspense = this.glIdByCode('200908'); + const defaults: Record = { + loanPortfolioAccountId: this.productGlAccountId || null, + fundSourceAccountId: cash, + transfersInSuspenseAccountId: cash, + interestOnLoanAccountId: interest, + incomeFromFeeAccountId: fee, + incomeFromPenaltyAccountId: penal, + incomeFromRecoveryAccountId: interest, + writeOffAccountId: writeOff, + goodwillCreditAccountId: writeOff, + chargeOffFraudExpenseAccountId: writeOff, + chargeOffExpenseAccountId: writeOff, + incomeFromChargeOffPenaltyAccountId: penal, + incomeFromChargeOffFeesAccountId: fee, + incomeFromChargeOffInterestAccountId: interest, + incomeFromGoodwillCreditInterestAccountId: interest, + incomeFromGoodwillCreditFeesAccountId: fee, + incomeFromGoodwillCreditPenaltyAccountId: penal, + receivableInterestAccountId: receivable, + receivableFeeAccountId: receivable, + receivablePenaltyAccountId: receivable, + overpaymentLiabilityAccountId: overpayment, + undebitedInterestAccountId: suspense + }; + Object.keys(defaults).forEach((name) => { + const control = this.loanProductAccountingForm.get(name); + if (control && !control.dirty && defaults[name]) { + control.setValue(defaults[name]); + } + }); } ngOnInit() { @@ -177,6 +261,7 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges { this.loanProductAccountingForm.get('accountingRule').disable(); this.buildAccountingSectionConfigs(); + this.applyDefaultMapping(); const accountingMappings = this.loanProductsTemplate.accountingMappings; this.setDeferredIncomeRecognitionControls(); @@ -277,6 +362,33 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges { 'incomeFromGoodwillCreditFeesAccountId', 'incomeFromGoodwillCreditPenaltyAccountId', 'receivableInterestAccountId', + 'receivableInterestAccountId', + 'receivableFeeAccountId', + 'receivablePenaltyAccountId', + 'intReceivablesGlAccountId', + 'intPayableGlAccountId', + 'deafIntPayableGlAccountId', + 'intOverdueReserveAccountId', + 'undebitedInterestAccountId', + 'penalIntCrGlAccountId', + 'rdMaturedGlAccountId' + ]; + // CBS-specific mappings the backend does not accept yet: optional here so a product can be created without + // them (empty values are dropped from the payload in LoanProducts.buildPayload). + const optionalCbsFields = [ + // derived by the backend from the nine mapped GLs (LoanGlMappingDefaults) + 'transfersInSuspenseAccountId', + 'incomeFromRecoveryAccountId', + 'goodwillCreditAccountId', + 'chargeOffFraudExpenseAccountId', + 'chargeOffExpenseAccountId', + 'incomeFromChargeOffPenaltyAccountId', + 'incomeFromChargeOffFeesAccountId', + 'incomeFromChargeOffInterestAccountId', + 'incomeFromGoodwillCreditInterestAccountId', + 'incomeFromGoodwillCreditFeesAccountId', + 'incomeFromGoodwillCreditPenaltyAccountId', + 'receivableInterestAccountId', 'receivableFeeAccountId', 'receivablePenaltyAccountId', 'intReceivablesGlAccountId', @@ -289,7 +401,10 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges { ]; commonFields.forEach((field) => { if (!this.loanProductAccountingForm.contains(field)) { - this.loanProductAccountingForm.addControl(field, new UntypedFormControl('', Validators.required)); + this.loanProductAccountingForm.addControl( + field, + new UntypedFormControl('', optionalCbsFields.includes(field) ? null : Validators.required) + ); } }); @@ -567,8 +682,20 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges { } = this.loanProductAccountingForm.getRawValue(); const otherGlMappings = this.getOtherGlMappings(); + // Only the nine mapped GLs are sent. The other Fineract placeholders (recovery, goodwill credit, charge-off ...) + // are derived by the backend. Interest Suspense has no core mapping, so it is kept on the satellite record. + const payload: any = {}; + this.basicControls.forEach((name) => { + if (name !== 'undebitedInterestAccountId' && loanProductAccounting[name]) { + payload[name] = loanProductAccounting[name]; + } + }); + if (loanProductAccounting.undebitedInterestAccountId) { + payload.undebitedInterestGlId = loanProductAccounting.undebitedInterestAccountId; + } return { - ...loanProductAccounting, + accountingRule: loanProductAccounting.accountingRule, + ...payload, ...(otherGlMappings ? { otherGlMappings } : {}) }; } @@ -684,7 +811,7 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges { inputLabel: 'Interest Receivable', controlName: 'receivableInterestAccountId', options: this.assetAccountData, - required: true, + required: false, accrualOnly: true }, { diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.html index ed9f6e540..b14b16918 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.html +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.html @@ -77,6 +77,8 @@ @if (isFdTenureLocked) { Between 12 and 15 + } + @if (isFdTenureLocked) { Must be between 12 and 15 months } diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.html index cbac09492..6054778dc 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.html +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.html @@ -42,10 +42,6 @@ Max Books Outstanding - - Reissue When Unused Leaves ≤ - - First Cheque Book Free @@ -66,10 +62,6 @@ } - - Cheque Print Description - - diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.ts index 3eade52c6..bbf4796cf 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.ts @@ -62,11 +62,9 @@ export class LoanProductChequeBookStepComponent { ], defaultBookSize: ['25'], maxBooksOutstanding: [1], - reissueWhenLeaves: [5], firstBookFree: ['YES'], freeBooksPerYear: [1], namePrinted: ['Account holder name'], - chequePrintDescription: [''], ppsThreshold: [ 50000, [ diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts index 6eb65abd2..e2f9631f6 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts @@ -105,6 +105,29 @@ export class LoanProductDetailsStepComponent implements OnInit { startDate: this.loanProductsTemplate.startDate ? new Date(this.loanProductsTemplate.startDate) : new Date(), closeDate: this.loanProductsTemplate.closeDate && new Date(this.loanProductsTemplate.closeDate) }); + + // A saved Advances / Loans product comes back with its type in the extension record. Show it filled and + // lock it: changing the type would change every other tab. + const ext = this.loanProductsTemplate?.advanceExtensions; + if (this.isExistingProduct && ext?.productTypeGroup) { + this.loanProductDetailsForm.patchValue({ + productTypeGroup: ext.productTypeGroup, + productTypeSubGroup: ext.productTypeSubGroup, + glAccount: ext.glAccount ?? '', + loanProductCategory: ext.loanProductCategory ?? this.loanProductDetailsForm.value.loanProductCategory, + prioritySector: ext.prioritySector ?? this.loanProductDetailsForm.value.prioritySector, + cibilAccountType: ext.cibilAccountType ?? this.loanProductDetailsForm.value.cibilAccountType, + kfsEnabled: this.yesNo(ext.kfsEnabled, this.loanProductDetailsForm.value.kfsEnabled), + aprEnabled: this.yesNo(ext.aprEnabled, this.loanProductDetailsForm.value.aprEnabled) + }); + this.loanProductDetailsForm.get('productTypeGroup').disable({ emitEvent: false }); + this.loanProductDetailsForm.get('productTypeSubGroup').disable({ emitEvent: false }); + } + } + + /** A saved YES / NO choice comes back as true / false. */ + private yesNo(saved: any, fallback: any): any { + return typeof saved === 'boolean' ? (saved ? 'YES' : 'NO') : (saved ?? fallback); } /** True when editing a saved product; see ngOnInit. */ @@ -229,7 +252,7 @@ export class LoanProductDetailsStepComponent implements OnInit { } get loanProductDetails() { - const loanProductDetailsFormData = this.loanProductDetailsForm.value; + const loanProductDetailsFormData = this.loanProductDetailsForm.getRawValue(); const prevStartDate: Date = this.loanProductDetailsForm.value.startDate; const prevCloseDate: Date = this.loanProductDetailsForm.value.closeDate; const dateFormat = this.settingsService.dateFormat; diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-eligibility-docs-step/loan-product-eligibility-docs-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-eligibility-docs-step/loan-product-eligibility-docs-step.component.html new file mode 100644 index 000000000..61856c68b --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-eligibility-docs-step/loan-product-eligibility-docs-step.component.html @@ -0,0 +1,60 @@ + + + +
+
+ +

Borrower Eligibility

+
+
+
+ + +
+
+
+ +
+
+ +

Required Documents

+ {{ selectedCount }} of {{ documentOptions.length }} selected +
+
+ @for (group of documentGroups; track group.title) { +
+
{{ group.title }}
+
+ @for (doc of documentsOf(group); track doc.id) { + + } +
+
+ } +
+
+ diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-eligibility-docs-step/loan-product-eligibility-docs-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-eligibility-docs-step/loan-product-eligibility-docs-step.component.ts new file mode 100644 index 000000000..5137fa6b5 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-eligibility-docs-step/loan-product-eligibility-docs-step.component.ts @@ -0,0 +1,163 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, Input, OnInit, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Borrower eligibility and document checklist, common to Advances A/C and Loans A/C. Age, CIBIL, income and + * FOIR already live on the Loans amount/tenure step, so only the rules both categories share are held here. + * Sent as satellite params (LoanProductAdvanceExtService.ADVANCE_PARAMETERS). + */ +@Component({ + selector: 'mifosx-loan-product-eligibility-docs-step', + templateUrl: './loan-product-eligibility-docs-step.component.html', + styleUrls: ['../advance-steps.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatCheckbox + ] +}) +export class LoanProductEligibilityDocsStepComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + + @Input() productTypeGroup: UntypedFormControl; + + loanProductEligibilityDocsForm: UntypedFormGroup; + + readonly documentOptions = [ + { id: 'KYC', label: 'KYC (ID proof)' }, + { id: 'ADDRESS_PROOF', label: 'Address proof' }, + { id: 'INCOME_PROOF', label: 'Income proof / salary certificate' }, + { id: 'ITR', label: 'Income-tax returns (3 years)' }, + { id: 'DEMAND_PROMISSORY_NOTE', label: 'Demand promissory note' }, + { id: 'LOAN_AGREEMENT', label: 'Loan agreement' }, + { id: 'GUARANTEE_DEED', label: 'Guarantor agreement' }, + { id: 'SALARY_DEDUCTION_AUTHORITY', label: 'Employer salary-deduction authority' }, + { id: 'LETTER_OF_LIEN', label: 'Letter of lien and set-off' }, + { id: 'LETTER_OF_CONTINUITY', label: 'Letter of continuity' }, + { id: 'HYPOTHECATION_AGREEMENT', label: 'Hypothecation agreement' }, + { id: 'TITLE_DEED', label: 'Title deed / registry' }, + { id: 'VALUATION_REPORT', label: 'Valuation report' }, + { id: 'STOCK_STATEMENT', label: 'Stock statement' }, + { id: 'INSURANCE_POLICY', label: 'Insurance policy (joint name)' }, + { id: 'QUOTATION', label: 'Dealer quotation / estimate' } + ]; + + /** The checklist grouped the way an officer thinks about the papers. Every document sits in exactly one group. */ + readonly documentGroups: { title: string; icon: string; ids: string[] }[] = [ + { title: 'Identity and address', icon: 'fa-id-card', ids: [ + 'KYC', + 'ADDRESS_PROOF' + ] }, + { title: 'Income', icon: 'fa-money-bill-wave', ids: [ + 'INCOME_PROOF', + 'ITR', + 'SALARY_DEDUCTION_AUTHORITY' + ] }, + { + title: 'Agreements', + icon: 'fa-file-signature', + ids: [ + 'DEMAND_PROMISSORY_NOTE', + 'LOAN_AGREEMENT', + 'GUARANTEE_DEED', + 'HYPOTHECATION_AGREEMENT' + ] + }, + { title: 'Lien and continuity', icon: 'fa-lock', ids: [ + 'LETTER_OF_LIEN', + 'LETTER_OF_CONTINUITY' + ] }, + { + title: 'Security papers', + icon: 'fa-shield-halved', + ids: [ + 'TITLE_DEED', + 'VALUATION_REPORT', + 'STOCK_STATEMENT', + 'INSURANCE_POLICY', + 'QUOTATION' + ] + } + ]; + + documentsOf(group: { ids: string[] }): { id: string; label: string }[] { + return this.documentOptions.filter((doc) => group.ids.includes(doc.id)); + } + + get selectedCount(): number { + return (this.loanProductEligibilityDocsForm.get('requiredDocuments')?.value ?? []).length; + } + + private readonly defaultDocuments: Record = { + advances: [ + 'KYC', + 'DEMAND_PROMISSORY_NOTE', + 'LETTER_OF_LIEN', + 'LETTER_OF_CONTINUITY' + ], + loans: [ + 'KYC', + 'ADDRESS_PROOF', + 'INCOME_PROOF', + 'LOAN_AGREEMENT', + 'GUARANTEE_DEED' + ] + }; + + constructor() { + this.loanProductEligibilityDocsForm = this.formBuilder.group({ + memberRequired: [true], + guarantorMustBeMember: [true], + requiredDocuments: [[]] + }); + } + + ngOnInit() { + if (this.productTypeGroup) { + this.applyDefaultDocuments(this.productTypeGroup.value); + this.productTypeGroup.valueChanges.subscribe((group: string) => this.applyDefaultDocuments(group)); + } + } + + /** Pre-ticks the usual checklist for the category until the user edits it. */ + private applyDefaultDocuments(group: string): void { + const control = this.loanProductEligibilityDocsForm.get('requiredDocuments'); + if (control && !control.dirty && this.defaultDocuments[group]) { + control.setValue([...this.defaultDocuments[group]]); + } + } + + isSelected(id: string): boolean { + return (this.loanProductEligibilityDocsForm.get('requiredDocuments')?.value ?? []).includes(id); + } + + toggleDocument(id: string, checked: boolean): void { + const control = this.loanProductEligibilityDocsForm.get('requiredDocuments'); + const current: string[] = control?.value ?? []; + control?.setValue( + checked + ? [ + ...new Set([ + ...current, + id + ]) + ] + : current.filter((d) => d !== id) + ); + control?.markAsDirty(); + } + + get loanProductEligibilityDocs() { + const value = this.loanProductEligibilityDocsForm.getRawValue(); + return { ...value, requiredDocuments: (value.requiredDocuments ?? []).join(',') }; + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.ts index 162f2cd8d..938eb97ec 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.ts @@ -33,12 +33,12 @@ export class LoanProductLoanRepaymentStepComponent { loanProductLoanRepaymentForm: UntypedFormGroup; + // Bullet / Step-up / Balloon live on the Advances A/C "Limit & Tenure" step instead — a Loans A/C + // (EMI) product always repays on a fixed instalment schedule, so only these three belong here. readonly repaymentTypeOptions = [ { id: 'emi', label: 'EMI', hint: 'Reducing balance, equal instalments' }, { id: 'equal_principal', label: 'Equal principal', hint: 'Falling instalments' }, - { id: 'flat', label: 'Flat rate', hint: 'Interest on original amount' }, - { id: 'bullet', label: 'Bullet', hint: 'Pay everything at maturity' }, - { id: 'step', label: 'Step-up / Balloon', hint: 'Custom schedule' } + { id: 'flat', label: 'Flat rate', hint: 'Interest on original amount' } ]; /** `fineractId` is Fineract's repaymentFrequencyType (0 days, 1 weeks, 2 months). */ diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.ts index 39caf70e0..6cc263c42 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.ts @@ -6,16 +6,45 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, inject } from '@angular/core'; -import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { Component, Input, OnInit, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms'; import { MatCheckbox } from '@angular/material/checkbox'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +interface SecurityProfile { + securityType: string; + securityMandatory: 'yes' | 'no'; + margin: number; + guarantorsRequired: number; +} + +/** Sensible defaults by Loans A/C sub type — the officer can still override every field. */ +const SECURITY_PROFILES: Record = { + personal: { securityType: 'None (unsecured)', securityMandatory: 'no', margin: 0, guarantorsRequired: 0 }, + education: { securityType: 'None (unsecured)', securityMandatory: 'no', margin: 0, guarantorsRequired: 0 }, + housing: { + securityType: 'Immovable property (mortgage)', + securityMandatory: 'yes', + margin: 20, + guarantorsRequired: 1 + }, + vehicle: { securityType: 'Vehicle (hypothecation)', securityMandatory: 'yes', margin: 15, guarantorsRequired: 0 }, + business: { + securityType: 'Immovable property / business assets', + securityMandatory: 'yes', + margin: 25, + guarantorsRequired: 1 + }, + gold_emi: { securityType: 'Gold ornaments (pledge)', securityMandatory: 'yes', margin: 25, guarantorsRequired: 0 }, + term: { securityType: 'Immovable property (mortgage)', securityMandatory: 'yes', margin: 20, guarantorsRequired: 1 } +}; + /** * Security & Collateral step for a Loans A/C (EMI) product (step 3). * * Unlike an Advance, the security only decides eligibility at sanction — it does not set a running - * limit. UI-only for now: not sent in the create payload. + * limit. Defaults adapt to the chosen sub type (e.g. Personal/Education default to unsecured, + * Housing/Vehicle/Gold default to their usual collateral) but every field stays editable. */ @Component({ selector: 'mifosx-loan-product-loan-security-step', @@ -26,9 +55,11 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; MatCheckbox ] }) -export class LoanProductLoanSecurityStepComponent { +export class LoanProductLoanSecurityStepComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); + @Input() productTypeSubGroup: UntypedFormControl; + loanProductLoanSecurityForm: UntypedFormGroup; readonly securityMandatoryOptions = [ @@ -112,6 +143,29 @@ export class LoanProductLoanSecurityStepComponent { }); } + ngOnInit(): void { + this.applySubGroup(this.productTypeSubGroup?.value); + // Keep the security defaults in step with the sub type chosen on the Details step. + this.productTypeSubGroup?.valueChanges.subscribe((subGroup: string) => this.applySubGroup(subGroup)); + } + + private applySubGroup(subGroup: string): void { + const profile = SECURITY_PROFILES[subGroup]; + if (!profile) { + return; + } + this.loanProductLoanSecurityForm.get('securityType').setValue(profile.securityType, { emitEvent: false }); + this.loanProductLoanSecurityForm.get('securityMandatory').setValue(profile.securityMandatory, { emitEvent: false }); + if (!this.loanProductLoanSecurityForm.get('margin').dirty) { + this.loanProductLoanSecurityForm.get('margin').setValue(profile.margin, { emitEvent: false }); + } + if (!this.loanProductLoanSecurityForm.get('guarantorsRequired').dirty) { + this.loanProductLoanSecurityForm + .get('guarantorsRequired') + .setValue(profile.guarantorsRequired, { emitEvent: false }); + } + } + /** Maximum LTV is derived from the margin (100 − margin) and shown read-only. */ get ltv(): number { const margin = Number(this.loanProductLoanSecurityForm.get('margin').value); diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.html index 30c8eda8e..d0981d2a6 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.html +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.html @@ -7,13 +7,14 @@ -->
-
+ +

Penal Charges

- + Penal Charges Applicable @for (opt of yesNoOptions; track opt) { @@ -21,11 +22,11 @@ } - + Penal Rate (% p.a.) - + Applied On @for (opt of appliedOnOptions; track opt.id) { @@ -33,7 +34,7 @@ } - + Penal Grace Days @@ -45,13 +46,14 @@
-
+ +
- -

Asset Classification (SMA / NPA)

+ +

SMA (Special Mention Account)

- + Delinquency Bucket (SMA) — None — @@ -61,11 +63,38 @@ SMA 0–30 / 31–60 / 61–90 - + + SMA Counting Starts After (months) + + + {{ isAdvanceAccount ? 'Advances: from disbursement (default 9)' : 'Loans: first EMI due (default 1)' }} + + + + In-Arrears Tolerance (₹) + + Small balance ignored for arrears + +
+
+ SMA-0 · 1–30 days + SMA-1 · 31–60 days + SMA-2 · 61–90 days +
+
+ + +
+
+ +

NPA (Non-Performing Asset)

+
+
+ NPA After (days overdue) - + NPA Trigger @for (opt of npaTriggerOptions; track opt.id) { @@ -73,7 +102,7 @@ } - + Interest after NPA @for (opt of interestAfterNpaOptions; track opt.id) { @@ -81,16 +110,31 @@ } - - Grace on Arrears Ageing (days) - - Days before an amount counts as overdue - - - In-Arrears Tolerance (₹) - - Small balance ignored for arrears - + @if (isRevolvingSubType) { + + NPA Out-of-Order Basis (CC/OD) + + @for (opt of npaOutOfOrderBasisOptions; track opt.id) { + {{ opt.label }} + } + + + + Stock Statement Validity (days) + + Older statement + drawings 90 days = NPA + + } + @if (isKccSubType) { + + Crop Duration (KCC) + + @for (opt of cropDurationOptions; track opt.id) { + {{ opt.label }} + } + + + }
@@ -103,4 +147,6 @@
+ + diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.ts index 0df4451de..c3da01979 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.ts @@ -6,14 +6,16 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, Input, inject } from '@angular/core'; -import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; +import { Component, Input, OnInit, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms'; import { MatCheckbox } from '@angular/material/checkbox'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** - * Penal charges & asset-classification (SMA / NPA) step for an Advances A/C product (Figma step 6). - * UI-only for now — not sent in the create payload. + * Penal charges & asset-classification (SMA / NPA / SICR / LGD) step for a loan product (Figma step 6). + * Shared by both Advances A/C and Loans A/C flows — fields are sent in the create/update payload via + * `loanProductPenalNpa`, mapped onto the core product fields and the LoanProductAdvanceExt satellite params + * in create-loan-product.component.ts. */ @Component({ selector: 'mifosx-loan-product-penal-npa-step', @@ -24,13 +26,30 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; MatCheckbox ] }) -export class LoanProductPenalNpaStepComponent { +export class LoanProductPenalNpaStepComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); @Input() loanProductsTemplate: any; + @Input() productTypeGroup: UntypedFormControl; + @Input() productTypeSubGroup: UntypedFormControl; loanProductPenalNpaForm: UntypedFormGroup; + /** True for Advances A/C (cc/overdraft/kcc style limit products); drives which SICR/LGD fields show. */ + isAdvanceAccount = false; + /** The ECL card is optional and starts collapsed. */ + eclOpen = false; + + /** One line for the collapsed ECL card. */ + get eclSummary(): string { + const v = this.loanProductPenalNpaForm.getRawValue(); + const days = this.isRevolvingSubType ? v.sicrRevolvingDaysThreshold : v.sicrDaysThreshold; + return `Stage 2 after ${days} days late · loss ${v.lgdSecuredPct}% with security, ${v.lgdUnsecuredPct}% without`; + } + + /** The chosen sub type (e.g. 'cc', 'overdraft', 'kcc', 'gold') within the current product type group. */ + subType: string | null = null; + readonly yesNoOptions = [ 'YES', 'NO' @@ -43,7 +62,8 @@ export class LoanProductPenalNpaStepComponent { readonly npaTriggerOptions = [ { id: 'maturity', label: 'Dues overdue after maturity' }, - { id: 'out_of_order', label: 'Out of order 90 days (CC/OD)' } + { id: 'out_of_order', label: 'Out of order 90 days (CC/OD)' }, + { id: 'crop_season', label: 'Overdue for crop season(s) (KCC)' } ]; readonly interestAfterNpaOptions = [ @@ -51,10 +71,47 @@ export class LoanProductPenalNpaStepComponent { { id: 'suspense', label: 'Move to Interest Suspense GL' } ]; + readonly sicrAssessmentBasisOptions = [ + { id: 'DPD', label: 'Days-Past-Due threshold only' }, + { id: 'RATING_DOWNGRADE', label: 'Internal rating downgrade only' }, + { id: 'PRICING_INCREASE', label: 'Risk-based repricing only' }, + { id: 'DPD_AND_RATING', label: 'DPD + rating downgrade (either triggers)' } + ]; + + /** RBI 2026: CC/OD "out of order" basis (para on revolving facilities). */ + readonly npaOutOfOrderBasisOptions = [ + { id: 'LIMIT_EXCEEDED', label: 'Outstanding continuously exceeds limit/drawing power' }, + { id: 'NO_CREDITS_90_DAYS', label: 'No credits for 90 days, or credits < interest debited' }, + { id: 'DP_NOT_RENEWED', label: 'Drawing power not reassessed/renewed within 180 days' } + ]; + + readonly cropDurationOptions = [ + { id: 'KHARIF', label: 'Kharif (short duration)' }, + { id: 'RABI', label: 'Rabi (short duration)' }, + { id: 'LONG_DURATION', label: 'Long duration crop' } + ]; + + readonly eligibleCollateralTypeOptions = [ + { id: 'NONE', label: 'None' }, + { id: 'GOLD', label: 'Gold / gold jewellery' }, + { id: 'GOVERNMENT_SECURITIES', label: 'Government securities' }, + { id: 'BANK_DEPOSIT', label: 'Bank term deposit (lien-marked)' }, + { id: 'LIFE_INSURANCE', label: 'Life insurance policy (surrender value)' } + ]; + get delinquencyBucketOptions(): any[] { return this.loanProductsTemplate?.delinquencyBucketOptions ?? []; } + /** CC/OD are revolving facilities — RBI's "out of order" NPA basis and 60-day SICR window apply to them. */ + get isRevolvingSubType(): boolean { + return this.subType === 'cc' || this.subType === 'overdraft'; + } + + get isKccSubType(): boolean { + return this.subType === 'kcc'; + } + constructor() { this.loanProductPenalNpaForm = this.formBuilder.group({ penalApplicable: ['YES'], @@ -78,13 +135,124 @@ export class LoanProductPenalNpaStepComponent { npaTrigger: ['maturity'], interestAfterNpa: ['undebited'], graceOnArrearsAgeing: [0], + smaStartAfterMonths: [ + 1, + [ + Validators.required, + Validators.min(0) + ] + ], + stockStatementValidityDays: [ + 90, + [Validators.min(0)] + ], inArrearsTolerance: [0], upgradeOnlyAllArrears: [true], - keepStandardIfDepositCovers: [true] + keepStandardIfDepositCovers: [true], + + // ---- SICR (Significant Increase in Credit Risk) — RBI 2026 ECL framework ---- + sicrDaysThreshold: [ + 30, + [Validators.min(0)] + ], + sicrRevolvingDaysThreshold: [ + 60, + [Validators.min(0)] + ], + sicrRatingDowngradeNotches: [ + 2, + [Validators.min(0)] + ], + sicrPricingIncreaseTriggerPct: [ + 1.5, + [ + Validators.min(0), + Validators.max(100) + ] + ], + sicrAssessmentBasis: ['DPD'], + sicrRebuttalAllowed: [false], + npaOutOfOrderBasis: ['LIMIT_EXCEEDED'], + cropDuration: [''], + + // ---- LGD (Loss Given Default) — regulatory backstop vs bank's own model ---- + useOwnLgdModel: [false], + lgdSecuredPct: [ + 65, + [ + Validators.min(0), + Validators.max(100) + ] + ], + lgdUnsecuredPct: [ + 70, + [ + Validators.min(0), + Validators.max(100) + ] + ], + eligibleCollateralType: ['NONE'] }); } + ngOnInit() { + if (this.productTypeGroup) { + this.applyProductTypeRules(this.productTypeGroup.value === 'advances', this.productTypeSubGroup?.value ?? null); + this.productTypeGroup.valueChanges.subscribe((group: string) => { + this.applyProductTypeRules(group === 'advances', this.productTypeSubGroup?.value ?? null); + }); + } + if (this.productTypeSubGroup) { + this.productTypeSubGroup.valueChanges.subscribe((subGroup: string) => { + this.applyProductTypeRules(this.productTypeGroup?.value === 'advances', subGroup); + }); + } + } + + /** + * CC/OD (revolving) default to the 60-day SICR window and show the "out of order" NPA basis; KCC shows + * the crop-duration field instead. Term/EMI loans use the plain 30-day DPD-based SICR window and hide + * both revolving-only fields. Only defaults the value the first time a sub type becomes relevant, so it + * doesn't clobber a value the user already edited. + */ + private applyProductTypeRules(isAdvance: boolean, subType: string | null): void { + this.isAdvanceAccount = isAdvance; + this.subType = isAdvance ? subType : null; + + if (this.isRevolvingSubType && !this.loanProductPenalNpaForm.get('sicrAssessmentBasis')?.dirty) { + this.loanProductPenalNpaForm.patchValue({ sicrAssessmentBasis: 'DPD_AND_RATING' }); + } + + // The security that backs the product decides which collateral counts for ECL. + const collateralBySubType: Record = { + fd: 'BANK_DEPOSIT', + rd: 'BANK_DEPOSIT', + dds: 'BANK_DEPOSIT', + lic: 'LIFE_INSURANCE', + gold: 'GOLD', + gold_emi: 'GOLD' + }; + const collateral = subType ? collateralBySubType[subType] : undefined; + if (!this.loanProductPenalNpaForm.get('eligibleCollateralType')?.dirty) { + this.loanProductPenalNpaForm.patchValue({ eligibleCollateralType: collateral ?? 'NONE' }); + } + + // SMA counting starts 9 months after disbursement for Advances, 1 month for Loans (EMI). + if (!this.loanProductPenalNpaForm.get('smaStartAfterMonths')?.dirty) { + this.loanProductPenalNpaForm.patchValue({ smaStartAfterMonths: isAdvance ? 9 : 1 }); + } + // NPA trigger follows the facility: CC/OD out-of-order, KCC crop season, everything else overdue days. + if (!this.loanProductPenalNpaForm.get('npaTrigger')?.dirty) { + const trigger = this.isRevolvingSubType ? 'out_of_order' : this.isKccSubType ? 'crop_season' : 'maturity'; + this.loanProductPenalNpaForm.patchValue({ npaTrigger: trigger }); + } + } + get loanProductPenalNpa() { - return this.loanProductPenalNpaForm.getRawValue(); + const value = this.loanProductPenalNpaForm.getRawValue(); + // Advances have no instalments, so the 9-month start is carried as arrears-ageing grace (30-day months). + // Loans (EMI) need none: the first instalment falls due one month after disbursement already. + const months = Number(value.smaStartAfterMonths) || 0; + return { ...value, graceOnArrearsAgeing: this.isAdvanceAccount ? months * 30 : 0 }; } } diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component.html index d86fe2322..e2b67b3eb 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component.html +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component.html @@ -6,12 +6,36 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> - - +
+

+ Check the details below. Use Submit at the bottom to send the product for approval. +

+ + @for (section of sections; track section.title) { +
+
{{ section.title }}
+
+ @for (item of section.rows; track item.label) { +
+ {{ item.label }} + {{ item.value }} +
+ } +
+
+ } + + @if (otherSettings.length > 0) { +
+ Other settings ({{ otherSettings.length }}) +
+ @for (item of otherSettings; track item.label) { +
+ {{ item.label }} + {{ item.value }} +
+ } +
+
+ } +
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component.scss b/src/app/products/loan-products/loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component.scss index 679255b76..ae57937a1 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component.scss +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component.scss @@ -47,3 +47,74 @@ mat-divider { .repayment-strategy { color: $status-approved; } + +/* Review step: sections of label / value rows. */ +.rv { + display: flex; + flex-direction: column; + gap: 14px; + + &__intro { + margin: 0; + color: #475569; + font-size: 13px; + } + + &__card { + border: 1px solid #e2e8f0; + border-radius: 10px; + background: #fff; + overflow: hidden; + } + + &__head { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + background: #f8fafc; + color: #1e293b; + font-size: 14px; + font-weight: 600; + cursor: default; + + i { + color: #6366f1; + } + } + + &__other > &__head { + cursor: pointer; + } + + &__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 24px; + padding: 6px 16px 10px; + + @media (width <= 900px) { + grid-template-columns: 1fr; + } + } + + &__row { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 7px 0; + border-bottom: 1px solid #f1f5f9; + font-size: 13px; + } + + &__label { + color: #64748b; + } + + &__value { + color: #1e293b; + font-weight: 500; + text-align: right; + word-break: break-word; + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component.ts index 87b1a8914..fbbb353aa 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component.ts @@ -6,44 +6,246 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, OnInit, Input, Output, EventEmitter, OnChanges, SimpleChanges } from '@angular/core'; -import { LoanProducts } from '../../loan-products'; -import { LoanProductSummaryComponent } from '../../common/loan-product-summary/loan-product-summary.component'; -import { FaIconComponent } from '@fortawesome/angular-fontawesome'; -import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { productCategoryLabel, productTypeLabel } from '../../loan-product-types'; +interface ReviewRow { + label: string; + value: string; +} + +interface ReviewSection { + title: string; + icon: string; + rows: ReviewRow[]; +} + +/** + * Review step for the Advances and Loans wizards: a plain read-out of what is about to be submitted, in sections, with + * the GL accounts shown by name. (The standard Fineract summary expects option objects this wizard does not build.) + */ @Component({ selector: 'mifosx-loan-product-preview-step', templateUrl: './loan-product-preview-step.component.html', styleUrls: ['./loan-product-preview-step.component.scss'], - imports: [ - ...STANDALONE_SHARED_IMPORTS, - LoanProductSummaryComponent, - FaIconComponent - ] + imports: [] }) -export class LoanProductPreviewStepComponent implements OnInit, OnChanges { +export class LoanProductPreviewStepComponent { @Input() loanProductsTemplate: any; @Input() accountingRuleData: any; @Input() loanProduct: any; - @Input() isValid: boolean = true; + @Input() isValid = true; @Output() submitEvent = new EventEmitter(); - isAdvancedPaymentAllocation = false; + /** Keys shown in a section above; everything else non-empty goes to "Other settings". */ + private readonly shown = new Set(); + private cachedFor: any = null; + private cachedSections: ReviewSection[] = []; + private cachedOther: ReviewRow[] = []; - constructor() {} + /** Keys that are technical or already covered, never shown. */ + private readonly hidden = new Set([ + 'locale', + 'dateFormat', + 'charges', + 'accountingRule', + 'inMultiplesOf', + 'repaymentEvery', + 'repaymentFrequencyType', + 'interestRateFrequencyType', + 'amortizationType', + 'interestType', + 'interestCalculationPeriodType', + 'transactionProcessingStrategyCode', + 'daysInYearType', + 'daysInMonthType', + 'isInterestRecalculationEnabled', + 'glAccount', + 'interestRatePerPeriod' + ]); - ngOnInit() { - this.advancedPaymentAllocation(); + get sections(): ReviewSection[] { + this.build(); + return this.cachedSections; } - ngOnChanges(changes: SimpleChanges): void { - this.advancedPaymentAllocation(); + get otherSettings(): ReviewRow[] { + this.build(); + return this.cachedOther; } - advancedPaymentAllocation() { - this.isAdvancedPaymentAllocation = LoanProducts.isAdvancedPaymentAllocationStrategy( - this.loanProduct.transactionProcessingStrategyCode - ); + /** Rebuilt only when the product object changes (it is stable until a form value changes). */ + private build(): void { + const p = this.loanProduct; + if (!p || p === this.cachedFor) { + return; + } + this.cachedFor = p; + this.shown.clear(); + + const row = (label: string, key: string, format?: (value: any) => string): ReviewRow | null => { + this.shown.add(key); + const value = p[key]; + if (value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0)) { + return null; + } + return { label, value: format ? format(value) : this.format(value) }; + }; + const gl = (label: string, key: string) => row(label, key, (id) => this.glName(id)); + const pct = (value: any) => `${value}%`; + const months = (value: any) => `${value} months`; + const rupees = (value: any) => `₹ ${Number(value).toLocaleString('en-IN')}`; + + const sections: ReviewSection[] = [ + { + title: 'Product', + icon: 'fa-hand-holding-usd', + rows: [ + { label: 'Type', value: productCategoryLabel(p.productTypeGroup) }, + { label: 'Sub type', value: productTypeLabel(p.productTypeGroup, p.productTypeSubGroup) }, + row('Name', 'name'), + row('Short name', 'shortName'), + gl('Product GL (asset)', 'glAccount'), + row('Start date', 'startDate'), + row('Close date', 'closeDate'), + row('Currency', 'currencyCode'), + row('Decimal places', 'digitsAfterDecimal') + ].filter((r): r is ReviewRow => !!r) + }, + { + title: 'Amount and tenure', + icon: 'fa-coins', + rows: [ + row('Facility type', 'facilityType'), + row('Minimum amount / limit', 'minPrincipal', rupees), + row('Default amount / limit', 'principal', rupees), + row('Maximum amount / limit', 'maxPrincipal', rupees), + row('Minimum tenure', 'minNumberOfRepayments', months), + row('Default tenure', 'numberOfRepayments', months), + row('Maximum tenure', 'maxNumberOfRepayments', months), + row('Repayment type', 'repaymentType'), + row('Appropriation order', 'appropriationOrder') + ].filter((r): r is ReviewRow => !!r) + }, + { + title: 'Interest', + icon: 'fa-percent', + rows: [ + row('Rate register row', 'registerParticular'), + p.interestRatePerPeriod !== undefined + ? { label: 'Rate (default)', value: pct(p.interestRatePerPeriod) } + : null, + row('Spread', 'spread', pct), + row('Calculation method', 'interestCalcMethod'), + row('Interest charged on', 'interestChargedOn'), + row('Posting frequency', 'interestPostingFrequency'), + row('Posting day', 'interestPostingDay') + ].filter((r): r is ReviewRow => !!r) + }, + { + title: 'Security and lien', + icon: 'fa-shield-halved', + rows: [ + row('Security type', 'securityType'), + row('Eligible amount basis', 'eligibleAmountBasis'), + row('Margin', 'margin', pct), + row('LTV', 'ltv', pct), + row('Lien required', 'lienRequired'), + row('Lien marking stage', 'lienMarkingStage'), + row('Eligible deposit products', 'eligibleDepositProducts'), + row('Lien certificate prefix', 'lienCertificatePrefix'), + row('Tenure cannot exceed deposit maturity', 'lienTenureNotExceedMaturity'), + row('Auto-release lien on closure', 'lienAutoReleaseOnClosure'), + row('Multiple deposits for one advance', 'lienAllowMultipleDeposits'), + row('Adjust from deposit at maturity', 'lienAdjustFromMaturity'), + row('Third-party deposit allowed', 'lienAllowThirdParty'), + row('Deposit renewal under lien', 'lienAllowRenewalUnderLien') + ].filter((r): r is ReviewRow => !!r) + }, + { + title: 'Penal, SMA and NPA', + icon: 'fa-triangle-exclamation', + rows: [ + row('Penal charges', 'penalApplicable'), + row('Penal rate', 'penalRate', pct), + row('Penal applied on', 'penalAppliedOn'), + row('Penal grace days', 'penalGraceDays'), + row('SMA counting starts after', 'smaStartAfterMonths', months), + row('NPA after', 'overdueDaysForNPA', (v) => `${v} days overdue`), + row('NPA trigger', 'npaTrigger'), + row('Stock statement validity', 'stockStatementValidityDays', (v) => `${v} days`), + row('Interest after NPA', 'interestAfterNpa'), + row('Upgrade only when all arrears are cleared', 'npaUpgradeOnlyAllArrears'), + row('Keep standard while deposit covers dues', 'npaKeepStandardIfDepositCovers') + ].filter((r): r is ReviewRow => !!r) + }, + { + title: 'Renewal and closure', + icon: 'fa-rotate', + rows: [ + row('Renewal allowed', 'renewalAllowed'), + row('Renewal period', 'renewalPeriodMonths', months), + row('Renewal grace', 'renewalGraceDays', (v) => `${v} days`), + row('Pre-closure', 'preclosureAllowed'), + row('Lock-in', 'lockInMonths', months), + row('Interest on pre-closure', 'interestOnPreclosure') + ].filter((r): r is ReviewRow => !!r) + }, + { + title: 'Accounting (GL accounts)', + icon: 'fa-book', + rows: [ + gl('Loan portfolio', 'loanPortfolioAccountId'), + gl('Fund source', 'fundSourceAccountId'), + gl('Interest income', 'interestOnLoanAccountId'), + gl('Fee income', 'incomeFromFeeAccountId'), + gl('Penal income', 'incomeFromPenaltyAccountId'), + gl('Write-off', 'writeOffAccountId'), + gl('Interest receivable', 'receivableInterestAccountId'), + gl('Overpayment', 'overpaymentLiabilityAccountId'), + gl('Interest suspense', 'undebitedInterestGlId') + ].filter((r): r is ReviewRow => !!r) + } + ].filter((section) => section.rows.length > 0); + + // The group and sub type are shown through the label row above. + this.shown.add('productTypeGroup'); + this.shown.add('productTypeSubGroup'); + + this.cachedSections = sections; + this.cachedOther = Object.keys(p) + .filter((key) => !this.shown.has(key) && !this.hidden.has(key)) + .filter( + (key) => p[key] !== undefined && p[key] !== null && p[key] !== '' && !(Array.isArray(p[key]) && !p[key].length) + ) + .map((key) => ({ label: this.humanize(key), value: this.format(p[key]) })); + } + + private glName(id: any): string { + const options = this.loanProductsTemplate?.accountingMappingOptions ?? {}; + const all: any[] = [ + ...(options.assetAccountOptions ?? []), + ...(options.incomeAccountOptions ?? []), + ...(options.expenseAccountOptions ?? []), + ...(options.liabilityAccountOptions ?? []), + ...(options.equityAccountOptions ?? []) + ]; + const account = all.find((entry) => String(entry.id) === String(id)); + return account ? `${account.glCode} ${account.name}` : String(id); + } + + private humanize(key: string): string { + const spaced = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' '); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); + } + + private format(value: any): string { + if (typeof value === 'boolean') { + return value ? 'Yes' : 'No'; + } + if (Array.isArray(value)) { + return value.map((entry) => (typeof entry === 'object' ? JSON.stringify(entry) : entry)).join(', '); + } + return typeof value === 'object' ? JSON.stringify(value) : String(value); } } diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.ts index d0327c94a..318cb7292 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.ts @@ -85,9 +85,18 @@ export class LoanProductSecurityLienStepComponent implements OnInit { 'Fixed Deposit – Regular', 'Fixed Deposit – Senior Citizen', 'Recurring Deposit – Regular', - 'Daily Deposit Scheme' + 'Daily Deposit Scheme', + 'LIC Policy' ]; + /** The deposit or policy a sub type is lent against; this is the default "Eligible Deposit Products". */ + private readonly defaultDepositBySubGroup: Record = { + fd: 'Fixed Deposit – Regular', + rd: 'Recurring Deposit – Regular', + dds: 'Daily Deposit Scheme', + lic: 'LIC Policy' + }; + readonly yesNoOptions = [ 'YES', 'NO' @@ -111,6 +120,13 @@ export class LoanProductSecurityLienStepComponent implements OnInit { if (margin != null && !this.loanProductSecurityLienForm.get('margin').dirty) { this.loanProductSecurityLienForm.get('margin').setValue(margin, { emitEvent: false }); } + // The deposit list follows the sub type until the officer has chosen their own. + // A sub type with no deposit of its own (gold, CC ...) keeps the plain FD default; the list is hidden for it. + const deposit = this.defaultDepositBySubGroup[subGroup] ?? 'Fixed Deposit – Regular'; + const deposits = this.loanProductSecurityLienForm.get('eligibleDepositProducts'); + if (deposit && deposits && !deposits.dirty) { + deposits.setValue([deposit], { emitEvent: false }); + } } /** This whole step is only relevant to an Advances A/C product. */ diff --git a/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.ts b/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.ts index 44d9a35cc..1d92c19cb 100644 --- a/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.ts @@ -45,6 +45,11 @@ export interface RegisterRateSelection { const PARTICULAR_BY_SUB_TYPE: Record = { fd: 'against fdr', rd: 'against rd', + dds: 'against dds', + lic: 'against lic', + warehouse: 'warehouse', + bills: 'bills purchased', + education: 'education loan', gold: 'gold loan', cc: 'cash credit', overdraft: 'overdraft', diff --git a/src/app/products/loan-products/loan-products.component.html b/src/app/products/loan-products/loan-products.component.html index 676738f06..3f1726628 100644 --- a/src/app/products/loan-products/loan-products.component.html +++ b/src/app/products/loan-products/loan-products.component.html @@ -81,9 +81,33 @@ placeholder="Search by name or short name…" />
+
+ + + +
- @if (totalCount === 0) { + @if (totalCount === 0 && pendingRows.length === 0) {

No loan products yet

@@ -123,6 +147,16 @@ + + Category + {{ categoryOf(p) }} + + + + Product Type + {{ typeOf(p) }} + + {{ 'labels.inputs.Expiry Date' | translate }} @@ -137,48 +171,100 @@ {{ 'labels.inputs.Status' | translate }} - - - {{ - p.status === 'loanProduct.active' - ? ('labels.inputs.Active' | translate) - : ('labels.catalogs.Inactive' | translate) - }} - + @if (p.pending) { + + Pending approval + + } @else { + + + {{ + p.status === 'loanProduct.active' + ? ('labels.inputs.Active' | translate) + : ('labels.catalogs.Inactive' | translate) + }} + + @if (pendingUpdates.get(p.id); as update) { + + } + } Actions - - + @if (p.pending) { + + + } @else { + + + } - + @if (filteredCount === 0 && totalCount > 0) { diff --git a/src/app/products/loan-products/loan-products.component.scss b/src/app/products/loan-products/loan-products.component.scss index 48f1f175d..ca97febff 100644 --- a/src/app/products/loan-products/loan-products.component.scss +++ b/src/app/products/loan-products/loan-products.component.scss @@ -61,3 +61,18 @@ background: #ede9fe; color: #5b21b6; } + +.pl-status-pill--pending { + background: #fef3c7; + color: #92400e; + border: 1px solid #fcd34d; +} + +.pl-row--pending { + background: #fffbeb; +} + +.pl-action-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} diff --git a/src/app/products/loan-products/loan-products.component.ts b/src/app/products/loan-products/loan-products.component.ts index f316a73ab..5f95c5888 100644 --- a/src/app/products/loan-products/loan-products.component.ts +++ b/src/app/products/loan-products/loan-products.component.ts @@ -8,6 +8,7 @@ /** Angular Imports */ import { Component, OnInit, TemplateRef, ElementRef, ViewChild, AfterViewInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; import { MatPaginator } from '@angular/material/paginator'; import { MatSort, MatSortHeader } from '@angular/material/sort'; import { MatDialog } from '@angular/material/dialog'; @@ -27,8 +28,8 @@ import { import { ActivatedRoute, Router, RouterLink } from '@angular/router'; /** rxjs Imports */ -import { switchMap, catchError, finalize } from 'rxjs/operators'; -import { EMPTY } from 'rxjs'; +import { switchMap, catchError, finalize, map, tap } from 'rxjs/operators'; +import { EMPTY, forkJoin, of } from 'rxjs'; /* Custom Services */ import { PopoverService } from '../../configuration-wizard/popover/popover.service'; @@ -42,6 +43,9 @@ import { MatTooltip } from '@angular/material/tooltip'; import { StatusLookupPipe } from '../../pipes/status-lookup.pipe'; import { DateFormatPipe } from '../../pipes/date-format.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { ProductEditDiffDialogComponent } from 'app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component'; +import { withExtensions } from './loan-product-original.util'; +import { PendingProductViewDialogComponent } from './pending-product-view-dialog/pending-product-view-dialog.component'; import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS, productCategoryLabel, productTypeLabel } from './loan-product-types'; @Component({ @@ -50,6 +54,7 @@ import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS, productCategoryLabel, productTy styleUrls: ['./loan-products.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, + FormsModule, FaIconComponent, MatTable, MatSort, @@ -80,6 +85,10 @@ export class LoanProductsComponent implements OnInit, AfterViewInit { private errorHandler = inject(ErrorHandlerService); loanProductsData: any[] = []; + /** Create requests a checker has not approved yet; shown in the table with a Pending Approval status. */ + pendingRows: any[] = []; + /** Saved products that have an edit waiting for a checker, by product id (value: the pending request). */ + pendingUpdates = new Map(); displayedColumns: string[] = [ 'name', 'shortName', @@ -130,8 +139,8 @@ export class LoanProductsComponent implements OnInit, AfterViewInit { return this.totalCount - this.activeCount; } - @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; - @ViewChild(MatSort, { static: true }) sort: MatSort; + @ViewChild(MatPaginator) paginator: MatPaginator; + @ViewChild(MatSort) sort: MatSort; /* Reference of create loan product button */ @ViewChild('buttonCreateLoanProduct') buttonCreateLoanProduct: ElementRef; @@ -160,7 +169,75 @@ export class LoanProductsComponent implements OnInit, AfterViewInit { this.dataSource.sort = this.sort; this.dataSource.filterPredicate = (product: any) => this.matchesFilters(product); this.filteredCount = this.totalCount; - this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length)); + this.dataSource.connect().subscribe(() => (this.filteredCount = this.dataSource.filteredData.length)); + this.loadPendingRequests(); + } + + /** Pending loan product requests become table rows, so a submitted product is visible straight away. */ + private loadPendingRequests(): void { + this.productsService + .getPendingMakerCheckerByEntity('LOANPRODUCT') + .pipe( + catchError(() => of([])), + tap((entries: any[]) => { + this.pendingUpdates = new Map( + (entries ?? []) + .filter((entry) => (entry.actionName ?? '').toUpperCase() === 'UPDATE' && entry.resourceId != null) + .map( + (entry) => [ + Number(entry.resourceId), + entry + ] as [number, any] + ) + ); + }), + switchMap((entries: any[]) => + (entries ?? []).filter((entry) => (entry.actionName ?? '').toUpperCase() === 'CREATE').length + ? forkJoin( + entries + .filter((entry) => (entry.actionName ?? '').toUpperCase() === 'CREATE') + .map((entry) => + this.productsService.getLoanRateRegisterAuditDetail(entry.id).pipe( + catchError(() => of(null)), + map((audit) => this.pendingRow(entry, audit)) + ) + ) + ) + : of([]) + ) + ) + .subscribe((rows: any[]) => { + this.pendingRows = rows; + this.dataSource.data = [ + ...rows, + ...this.loanProductsData + ]; + }); + } + + private pendingRow(entry: any, audit: any): any { + let payload: any = {}; + try { + payload = audit?.commandAsJson ? JSON.parse(audit.commandAsJson) : {}; + } catch { + payload = {}; + } + return { + id: null, + pending: true, + pendingRequestId: entry.id, + name: payload.name ?? 'New loan product', + shortName: payload.shortName ?? '—', + currency: payload.currencyCode ? { code: payload.currencyCode } : null, + closeDate: null, + status: 'pending', + madeBy: entry.maker, + payload, + advanceExtensions: { + productTypeGroup: payload.productTypeGroup, + productTypeSubGroup: payload.productTypeSubGroup + } + }; } applyFilter(filterValue: string) { @@ -210,6 +287,8 @@ export class LoanProductsComponent implements OnInit, AfterViewInit { * To show popover. */ ngAfterViewInit() { + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; if (this.configurationWizardService.showLoanProductsPage) { setTimeout(() => { this.showPopover( @@ -267,6 +346,34 @@ export class LoanProductsComponent implements OnInit, AfterViewInit { /** * Opens the import loan product dialog. */ + /** What a pending edit would change: the saved product and the submitted edit, side by side. */ + viewUpdate(row: any): void { + const request = this.pendingUpdates.get(Number(row.id)); + if (!request) { + return; + } + forkJoin({ + audit: this.productsService.getLoanRateRegisterAuditDetail(request.id), + product: this.productsService.getLoanProduct(row.id, true) + }).subscribe(({ audit, product }: any) => { + let proposed: any = {}; + try { + proposed = audit?.commandAsJson ? JSON.parse(audit.commandAsJson) : {}; + } catch { + proposed = {}; + } + this.dialog.open(ProductEditDiffDialogComponent, { + width: '820px', + data: { productName: row.name, original: withExtensions(product), proposed } + }); + }); + } + + /** A pending product has no page yet, so its submitted values open in a read-only dialog. */ + viewPending(row: any): void { + this.dialog.open(PendingProductViewDialogComponent, { width: '40rem', data: row }); + } + openImportDialog(): void { const importDialogRef = this.dialog.open(ImportLoanProductDialogComponent, { width: '50rem' @@ -318,7 +425,10 @@ export class LoanProductsComponent implements OnInit, AfterViewInit { .subscribe({ next: (data: any) => { this.loanProductsData = data; - this.dataSource.data = this.loanProductsData; + this.dataSource.data = [ + ...this.pendingRows, + ...this.loanProductsData + ]; this.errorHandler.showSuccess('Loan product imported successfully!'); }, error: () => { diff --git a/src/app/products/loan-products/pending-product-view-dialog/pending-product-view-dialog.component.html b/src/app/products/loan-products/pending-product-view-dialog/pending-product-view-dialog.component.html new file mode 100644 index 000000000..d4dd97ce6 --- /dev/null +++ b/src/app/products/loan-products/pending-product-view-dialog/pending-product-view-dialog.component.html @@ -0,0 +1,39 @@ + +

{{ data.name }}

+ + +
+ + + Awaiting checker approval. Requested by {{ data.madeBy }}. This product is not active yet and cannot be edited until the request is approved or rejected. + +
+ +
+ {{ category }} + {{ type }} + Short name: {{ data.shortName }} +
+ + + + @for (field of fields; track field.label) { + + + + + } + +
{{ field.label }}{{ field.value }}
+
+ + + + diff --git a/src/app/products/loan-products/pending-product-view-dialog/pending-product-view-dialog.component.scss b/src/app/products/loan-products/pending-product-view-dialog/pending-product-view-dialog.component.scss new file mode 100644 index 000000000..9c28a80f7 --- /dev/null +++ b/src/app/products/loan-products/pending-product-view-dialog/pending-product-view-dialog.component.scss @@ -0,0 +1,74 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +.ppv__banner { + display: flex; + gap: 10px; + align-items: flex-start; + padding: 10px 14px; + margin-bottom: 12px; + border: 1px solid #fcd34d; + border-radius: 8px; + background: #fffbeb; + color: #78350f; + font-size: 13px; + + i { + margin-top: 2px; + color: #d97706; + } +} + +.ppv__chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; +} + +.ppv__chip { + padding: 3px 10px; + border-radius: 999px; + background: #fef3c7; + color: #92400e; + font-size: 12px; + font-weight: 600; +} + +.ppv__table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th, + td { + padding: 6px 8px; + border-bottom: 1px solid #f1f5f9; + text-align: left; + vertical-align: top; + } + + th { + width: 42%; + font-weight: 500; + color: #64748b; + } + + td { + color: #1e293b; + word-break: break-word; + } +} + +.ppv__close { + padding: 6px 16px; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #fff; + cursor: pointer; +} diff --git a/src/app/products/loan-products/pending-product-view-dialog/pending-product-view-dialog.component.ts b/src/app/products/loan-products/pending-product-view-dialog/pending-product-view-dialog.component.ts new file mode 100644 index 000000000..dba11e3ac --- /dev/null +++ b/src/app/products/loan-products/pending-product-view-dialog/pending-product-view-dialog.component.ts @@ -0,0 +1,78 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { + MAT_DIALOG_DATA, + MatDialogActions, + MatDialogClose, + MatDialogContent, + MatDialogTitle +} from '@angular/material/dialog'; +import { productCategoryLabel, productTypeLabel } from '../loan-product-types'; + +/** Submitted values that carry no information for a reader. */ +const HIDDEN_KEYS = new Set([ + 'locale', + 'dateFormat', + 'charges' +]); + +/** + * Read-only view of a loan product request that is still awaiting a checker. The product does not exist yet, so the + * page is built from what the maker submitted (the request payload). + */ +@Component({ + selector: 'mifosx-pending-product-view-dialog', + templateUrl: './pending-product-view-dialog.component.html', + styleUrls: ['./pending-product-view-dialog.component.scss'], + imports: [ + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose + ] +}) +export class PendingProductViewDialogComponent { + /** The pending table row: name, shortName, madeBy and the submitted payload. */ + data = inject(MAT_DIALOG_DATA); + + get category(): string { + return productCategoryLabel(this.data?.payload?.productTypeGroup); + } + + get type(): string { + return productTypeLabel(this.data?.payload?.productTypeGroup, this.data?.payload?.productTypeSubGroup); + } + + /** Every submitted field as label / value, hiding empty values and technical keys. */ + get fields(): { label: string; value: string }[] { + const payload = this.data?.payload ?? {}; + return Object.keys(payload) + .filter((key) => !HIDDEN_KEYS.has(key)) + .filter((key) => payload[key] !== null && payload[key] !== undefined && payload[key] !== '') + .map((key) => ({ label: this.label(key), value: this.format(payload[key]) })); + } + + private label(key: string): string { + const spaced = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' '); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); + } + + private format(value: any): string { + if (typeof value === 'boolean') { + return value ? 'Yes' : 'No'; + } + if (Array.isArray(value)) { + return value.length + ? value.map((entry) => (typeof entry === 'object' ? JSON.stringify(entry) : entry)).join(', ') + : '—'; + } + return typeof value === 'object' ? JSON.stringify(value) : String(value); + } +} diff --git a/src/app/products/manage-delinquency-buckets/delinquency-range/create-range/create-range.component.html b/src/app/products/manage-delinquency-buckets/delinquency-range/create-range/create-range.component.html index 13e3fe089..4b27c1b2f 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-range/create-range/create-range.component.html +++ b/src/app/products/manage-delinquency-buckets/delinquency-range/create-range/create-range.component.html @@ -48,6 +48,17 @@ {{ 'labels.inputs.Days Till' | translate }} + + + Stage (RBI 2026 ECL) + + — None (informational only) — + Stage 1 — Standard / performing + Stage 2 — SICR / underperforming + Stage 3 — NPA / credit-impaired + + Drives staging for ECL provisioning; leave blank for a non-staging range. +
diff --git a/src/app/products/manage-delinquency-buckets/delinquency-range/create-range/create-range.component.ts b/src/app/products/manage-delinquency-buckets/delinquency-range/create-range/create-range.component.ts index a58a558da..49f057e8c 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-range/create-range/create-range.component.ts +++ b/src/app/products/manage-delinquency-buckets/delinquency-range/create-range/create-range.component.ts @@ -61,15 +61,18 @@ export class CreateRangeComponent implements OnInit { Validators.pattern('^(0*[1-9][0-9]*)$'), Validators.max(10000) ] - ] + ], + // RBI 2026 ECL framework: which asset-classification stage this range maps to (optional). + stage: [''] }); } submit(): void { const delinquencyRangeFormData = this.delinquencyRangeForm.value; const locale = this.settingsService.language.code; - const data = { + const data: any = { ...delinquencyRangeFormData, + stage: delinquencyRangeFormData.stage === '' ? undefined : Number(delinquencyRangeFormData.stage), locale }; this.productsService.createDelinquencyRange(data).subscribe((response: any) => { diff --git a/src/app/products/manage-delinquency-buckets/delinquency-range/edit-range/edit-range.component.html b/src/app/products/manage-delinquency-buckets/delinquency-range/edit-range/edit-range.component.html index cdb5aa732..c6a0e2122 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-range/edit-range/edit-range.component.html +++ b/src/app/products/manage-delinquency-buckets/delinquency-range/edit-range/edit-range.component.html @@ -47,6 +47,17 @@ {{ 'labels.inputs.Days Till' | translate }} + + + Stage (RBI 2026 ECL) + + — None (informational only) — + Stage 1 — Standard / performing + Stage 2 — SICR / underperforming + Stage 3 — NPA / credit-impaired + + Drives staging for ECL provisioning; leave blank for a non-staging range. + diff --git a/src/app/products/manage-delinquency-buckets/delinquency-range/edit-range/edit-range.component.ts b/src/app/products/manage-delinquency-buckets/delinquency-range/edit-range/edit-range.component.ts index 2aacfa487..7f2f9d8de 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-range/edit-range/edit-range.component.ts +++ b/src/app/products/manage-delinquency-buckets/delinquency-range/edit-range/edit-range.component.ts @@ -68,15 +68,18 @@ export class EditRangeComponent implements OnInit { Validators.pattern('^(0*[1-9][0-9]*?)$'), Validators.max(10000) ] - ] + ], + // RBI 2026 ECL framework: which asset-classification stage this range maps to (optional). + stage: [this.delinquencyRangeData.stage ?? ''] }); } submit(): void { const delinquencyRangeFormData = this.delinquencyRangeForm.value; const locale = this.settingsService.language.code; - const data = { + const data: any = { ...delinquencyRangeFormData, + stage: delinquencyRangeFormData.stage === '' ? undefined : Number(delinquencyRangeFormData.stage), locale }; this.productsService.updateDelinquencyRange(this.delinquencyRangeData.id, data).subscribe((response: any) => { diff --git a/src/app/products/manage-delinquency-buckets/delinquency-range/view-range/view-range.component.html b/src/app/products/manage-delinquency-buckets/delinquency-range/view-range/view-range.component.html index 7bf7dad91..d49014d78 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-range/view-range/view-range.component.html +++ b/src/app/products/manage-delinquency-buckets/delinquency-range/view-range/view-range.component.html @@ -52,6 +52,25 @@ {{ 'labels.inputs.Days Till' | translate }} {{ delinquencyRangeData.maximumAgeDays }} +
+ Stage (RBI 2026 ECL) + + @switch (delinquencyRangeData.stage) { + @case (1) { + Stage 1 — Standard / performing + } + @case (2) { + Stage 2 — SICR / underperforming + } + @case (3) { + Stage 3 — NPA / credit-impaired + } + @default { + — None (informational only) — + } + } + +
diff --git a/src/app/products/manage-delinquency-buckets/manage-delinquency-buckets.component.html b/src/app/products/manage-delinquency-buckets/manage-delinquency-buckets.component.html index c3e24519c..7eab2eb76 100644 --- a/src/app/products/manage-delinquency-buckets/manage-delinquency-buckets.component.html +++ b/src/app/products/manage-delinquency-buckets/manage-delinquency-buckets.component.html @@ -34,5 +34,16 @@ + +
+
+ +
+
+

Prudential Floor Master

+

RBI 2026 ECL stage-wise provisioning floor, by product category

+
+ +
diff --git a/src/app/products/manage-delinquency-buckets/prudential-floor/prudential-floor.component.html b/src/app/products/manage-delinquency-buckets/prudential-floor/prudential-floor.component.html new file mode 100644 index 000000000..21d6d6570 --- /dev/null +++ b/src/app/products/manage-delinquency-buckets/prudential-floor/prudential-floor.component.html @@ -0,0 +1,64 @@ + + +
+
+
+

+ + Prudential Floor Master +

+

+ RBI 2026 ECL framework (para 82) — regulatory minimum provisioning floor by product category and stage. Seeded + from the RBI Directions; read-only. +

+
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Product Category{{ row.productCategory }}Stage{{ stageLabels[row.stage] ?? row.stage }}Duration in Stage 3{{ durationLabel(row) }}Secured Floor (%){{ row.securedFloorPct }}Unsecured Floor (%){{ row.unsecuredFloorPct }}
+ +
+
diff --git a/src/app/products/manage-delinquency-buckets/prudential-floor/prudential-floor.component.resolver.ts b/src/app/products/manage-delinquency-buckets/prudential-floor/prudential-floor.component.resolver.ts new file mode 100644 index 000000000..6b762f5d9 --- /dev/null +++ b/src/app/products/manage-delinquency-buckets/prudential-floor/prudential-floor.component.resolver.ts @@ -0,0 +1,32 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Injectable, inject } from '@angular/core'; + +/** rxjs Imports */ +import { Observable } from 'rxjs'; + +/** Custom Services */ +import { ProductsService } from '../../products.service'; + +/** + * Prudential Floor Master data resolver (RBI 2026 ECL framework, para 82). Read-only. + */ +@Injectable() +export class PrudentialFloorComponentsResolver { + private productsService = inject(ProductsService); + + /** + * Returns the prudential floor master rows. + * @returns {Observable} + */ + resolve(): Observable { + return this.productsService.getPrudentialFloors(); + } +} diff --git a/src/app/products/manage-delinquency-buckets/prudential-floor/prudential-floor.component.scss b/src/app/products/manage-delinquency-buckets/prudential-floor/prudential-floor.component.scss new file mode 100644 index 000000000..2b490caf1 --- /dev/null +++ b/src/app/products/manage-delinquency-buckets/prudential-floor/prudential-floor.component.scss @@ -0,0 +1,19 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +@use '../../../shared/styles/list-layout' as *; + +table { + width: 100%; +} + +.pf-subtitle { + margin: 4px 0 0; + color: rgb(0 0 0 / 60%); + font-size: 0.875rem; +} diff --git a/src/app/products/manage-delinquency-buckets/prudential-floor/prudential-floor.component.ts b/src/app/products/manage-delinquency-buckets/prudential-floor/prudential-floor.component.ts new file mode 100644 index 000000000..72cdbd1c0 --- /dev/null +++ b/src/app/products/manage-delinquency-buckets/prudential-floor/prudential-floor.component.ts @@ -0,0 +1,118 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, ViewChild, inject } from '@angular/core'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort, MatSortHeader } from '@angular/material/sort'; +import { + MatTableDataSource, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { ActivatedRoute } from '@angular/router'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * RBI 2026 ECL framework: stage-wise prudential floor master (para 82), by loan product category. + * Read-only — the master is seeded from the RBI Directions, not created per bank, so there is no + * create/edit screen here (a Board-approved "tighten only" override is a follow-up, see backend + * PrudentialFloorsApiResource). + */ +@Component({ + selector: 'mifosx-prudential-floor', + templateUrl: './prudential-floor.component.html', + styleUrls: ['./prudential-floor.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatTable, + MatSort, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatSortHeader, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + MatPaginator + ] +}) +export class PrudentialFloorComponent implements OnInit { + private route = inject(ActivatedRoute); + + prudentialFloorData: any[]; + /** Columns to be displayed in the prudential floor table. */ + displayedColumns: string[] = [ + 'productCategory', + 'stage', + 'duration', + 'securedFloorPct', + 'unsecuredFloorPct' + ]; + /** Data source for the prudential floor table. */ + dataSource: MatTableDataSource; + + /** Paginator for the prudential floor table. */ + @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; + /** Sorter for the prudential floor table. */ + @ViewChild(MatSort, { static: true }) sort: MatSort; + + readonly stageLabels: Record = { + 1: 'Stage 1', + 2: 'Stage 2', + 3: 'Stage 3' + }; + + constructor() { + this.route.data.subscribe((data: { prudentialFloors: any[] }) => { + this.prudentialFloorData = data.prudentialFloors; + }); + } + + ngOnInit(): void { + this.setDatasource(); + } + + /** + * Filters data in the prudential floor table based on passed value. + * @param {string} filterValue Value to filter data. + */ + applyFilter(filterValue: string) { + this.dataSource.filter = filterValue.trim().toLowerCase(); + } + + /** "0–365 days", "1461+ days" (open-ended) or "—" (Stage 1/2: not duration-banded). */ + durationLabel(row: any): string { + if (row.durationFromDays == null) { + return '—'; + } + if (row.durationToDays == null) { + return `${row.durationFromDays}+ days`; + } + return `${row.durationFromDays}–${row.durationToDays} days`; + } + + /** + * Initializes the data source, paginator and sorter for the prudential floor table. + */ + setDatasource() { + this.dataSource = new MatTableDataSource(this.prudentialFloorData); + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + } +} diff --git a/src/app/products/products-routing.module.ts b/src/app/products/products-routing.module.ts index 888d4de1c..1c2b462f1 100644 --- a/src/app/products/products-routing.module.ts +++ b/src/app/products/products-routing.module.ts @@ -120,6 +120,8 @@ import { DelinquencyBucketComponent } from './manage-delinquency-buckets/delinqu import { DelinquencyRangeComponent } from './manage-delinquency-buckets/delinquency-range/delinquency-range.component'; import { DelinquencyBucketComponentsResolver } from './manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.resolver'; import { DelinquencyRangeComponentsResolver } from './manage-delinquency-buckets/delinquency-range/delinquency-range.component.resolver'; +import { PrudentialFloorComponent } from './manage-delinquency-buckets/prudential-floor/prudential-floor.component'; +import { PrudentialFloorComponentsResolver } from './manage-delinquency-buckets/prudential-floor/prudential-floor.component.resolver'; import { ViewRangeComponent } from './manage-delinquency-buckets/delinquency-range/view-range/view-range.component'; import { EditRangeComponent } from './manage-delinquency-buckets/delinquency-range/edit-range/edit-range.component'; import { CreateRangeComponent } from './manage-delinquency-buckets/delinquency-range/create-range/create-range.component'; @@ -224,6 +226,15 @@ const routes: Routes = [ }, { path: 'edit', + component: CreateLoanProductComponent, + data: { title: 'Edit Loan Product', breadcrumb: 'Edit', routeParamBreadcrumb: false }, + resolve: { + loanProductAndTemplate: LoanProductAndTemplateResolver, + configurations: GlobalConfigurationsResolver + } + }, + { + path: 'edit-legacy', component: EditLoanProductComponent, data: { title: 'Edit Loan Product', breadcrumb: 'Edit', routeParamBreadcrumb: false }, resolve: { @@ -663,6 +674,14 @@ const routes: Routes = [ ] } ] + }, + { + path: 'floor-master', + component: PrudentialFloorComponent, + data: { title: 'Prudential Floor Master', breadcrumb: 'Prudential Floor Master' }, + resolve: { + prudentialFloors: PrudentialFloorComponentsResolver + } } ] }, @@ -1104,6 +1123,7 @@ const routes: Routes = [ CollateralTemplateResolver, DelinquencyRangeComponentsResolver, DelinquencyBucketComponentsResolver, + PrudentialFloorComponentsResolver, CurrentProductsResolver, CurrentProductsTemplateResolver, CurrentProductResolver, diff --git a/src/app/products/products.service.ts b/src/app/products/products.service.ts index 28645524c..d984cfcf4 100644 --- a/src/app/products/products.service.ts +++ b/src/app/products/products.service.ts @@ -12,7 +12,8 @@ import { HttpClient, HttpContext, HttpParams } from '@angular/common/http'; import { SILENT_HTTP_ERROR } from 'app/core/http/silent-error.token'; /** rxjs Imports */ -import { Observable } from 'rxjs'; +import { Observable, forkJoin, of } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; /** Custom Services */ import { SettingsService } from 'app/settings/settings.service'; @@ -583,6 +584,17 @@ export class ProductsService { return this.http.put(`/taxes/group/${taxGroupId}`, taxGroup); } + /** + * RBI 2026 ECL framework: stage-wise prudential floor master, by product category. Read-only — seeded + * from the RBI Directions (para 82) rather than created per bank. + * @param {string} [productCategory] Optional filter. + * @returns {Observable} Prudential Floor rows + */ + getPrudentialFloors(productCategory?: string): Observable { + const params = productCategory ? { productCategory } : {}; + return this.http.get('/prudentialfloors', { params }); + } + /** * @returns {Observable} Delinquency Range data */ @@ -867,9 +879,36 @@ export class ProductsService { } /** Requests of one entity (e.g. DELINQUENCY_RANGE) still awaiting a checker. */ + /** + * Pending requests for an entity: everything the user may approve (checker inbox) plus the requests the user + * submitted. The inbox alone hides a maker's own requests unless the maker also holds checker permission. + * Each entry carries canApprove, true only for those the user can actually approve. + */ getPendingMakerCheckerByEntity(entityName: string): Observable { const params = new HttpParams().set('entityName', entityName); - return this.http.get('/makercheckers', { params, context: new HttpContext().set(SILENT_HTTP_ERROR, true) }); + const context = new HttpContext().set(SILENT_HTTP_ERROR, true); + const toApprove$ = this.http + .get('/makercheckers', { params, context }) + .pipe(catchError(() => of([] as any[]))); + const mine$ = this.http + .get('/makercheckers/mine', { params, context }) + .pipe(catchError(() => of([] as any[]))); + return forkJoin([ + toApprove$, + mine$ + ]).pipe( + map( + ([ + toApprove, + mine + ]) => { + const byId = new Map(); + (mine ?? []).forEach((entry) => byId.set(entry.id, { ...entry, canApprove: false })); + (toApprove ?? []).forEach((entry) => byId.set(entry.id, { ...entry, canApprove: true })); + return [...byId.values()]; + } + ) + ); } /** Full maker-checker entry, including the submitted payload (commandAsJson), which the list call omits. */ diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts index 1ac9dcc54..57d5c43b4 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts @@ -63,9 +63,53 @@ export class ProductEditDiffDialogComponent implements OnInit { 'id', 'productId', 'savingsProductId', - 'otherGlMappings' + 'otherGlMappings', + // Loan / advance engine defaults and the locked type: not settings a reviewer decides on. + 'amortizationType', + 'interestType', + 'interestCalculationPeriodType', + 'transactionProcessingStrategyCode', + 'daysInYearType', + 'daysInMonthType', + 'isInterestRecalculationEnabled', + 'repaymentEvery', + 'repaymentFrequencyType', + 'interestRateFrequencyType', + 'accountingRule', + 'productTypeGroup', + 'productTypeSubGroup' ]); + /** Plain names for the loan / advance fields that otherwise read as raw parameter names. */ + private static readonly FRIENDLY_LABELS: Record = { + minPrincipal: 'Minimum amount / limit', + principal: 'Default amount / limit', + maxPrincipal: 'Maximum amount / limit', + minNumberOfRepayments: 'Minimum tenure (months)', + numberOfRepayments: 'Default tenure (months)', + maxNumberOfRepayments: 'Maximum tenure (months)', + interestRatePerPeriod: 'Default interest rate (%)', + minInterestRatePerPeriod: 'Minimum interest rate (%)', + maxInterestRatePerPeriod: 'Maximum interest rate (%)', + overdueDaysForNPA: 'NPA after (days overdue)', + graceOnArrearsAgeing: 'Arrears grace (days)', + smaStartAfterMonths: 'SMA counting starts after (months)', + penalRate: 'Penal rate (%)', + margin: 'Margin (%)', + ltv: 'LTV (%)', + glAccount: 'Product GL (asset)', + registerParticular: 'Rate register row', + loanPortfolioAccountId: 'Loan portfolio GL', + fundSourceAccountId: 'Fund source GL', + interestOnLoanAccountId: 'Interest income GL', + incomeFromFeeAccountId: 'Fee income GL', + incomeFromPenaltyAccountId: 'Penal income GL', + writeOffAccountId: 'Write-off GL', + overpaymentLiabilityAccountId: 'Overpayment GL', + receivableInterestAccountId: 'Interest receivable GL', + undebitedInterestGlId: 'Interest suspense GL' + }; + /** The two reserved otherGlMappings entries a checker actually needs to see by name -- see * AccountingProcessorHelper.SAVINGS_INTEREST_PAYABLE_MAPPING / SAVINGS_INTEREST_PAID_MAPPING. Any * other entry in the assets/liabilities/expenses/income lists is a free-form "Other" row with no @@ -84,6 +128,7 @@ export class ProductEditDiffDialogComponent implements OnInit { digitsAfterDecimal: 'currency.decimalPlaces', inMultiplesOf: 'currency.inMultiplesOf', taxGroupId: 'taxGroup.id', + delinquencyBucketId: 'delinquencyBucket.id', // Enum fields the stored product exposes as "Type": {id, value}", not "TypeId" -- // without these the "current" side always read as blank, so these showed as changed on every // edit even when the maker never touched them. @@ -124,7 +169,13 @@ export class ProductEditDiffDialogComponent implements OnInit { penaltiesReceivableAccountId: 'penaltyReceivableAccount', interestPayableAccountId: 'interestPayableAccount', interestReceivableAccountId: 'interestReceivableAccount', - escheatLiabilityId: 'escheatLiabilityAccount' + escheatLiabilityId: 'escheatLiabilityAccount', + // loan / advance products + fundSourceAccountId: 'fundSourceAccount', + loanPortfolioAccountId: 'loanPortfolioAccount', + interestOnLoanAccountId: 'interestOnLoanAccount', + overpaymentLiabilityAccountId: 'overpaymentLiabilityAccount', + receivableInterestAccountId: 'receivableInterestAccount' }; private glAccountNamesById = new Map(); @@ -143,7 +194,7 @@ export class ProductEditDiffDialogComponent implements OnInit { .map((key) => ({ key, originalValue: this.originalValueFor(key, original) })) .filter(({ key, originalValue }) => !sameValue(originalValue, proposed[key])) .map(({ key, originalValue }) => ({ - label: humanizeKey(key), + label: ProductEditDiffDialogComponent.FRIENDLY_LABELS[key] ?? humanizeKey(key), original: this.displayValue(originalValue, key, original), proposed: this.displayValue(proposed[key], key, original) })) diff --git a/src/app/tasks/command-diff.util.ts b/src/app/tasks/command-diff.util.ts index f7a19f941..e244fe83c 100644 --- a/src/app/tasks/command-diff.util.ts +++ b/src/app/tasks/command-diff.util.ts @@ -127,6 +127,10 @@ export function normalizeValue(value: any): any { if (typeof value === 'boolean') { return value; } + // A YES / NO select is saved as true / false: the same answer, so compare them as one. + if (typeof value === 'string' && /^(yes|no)$/i.test(value.trim())) { + return value.trim().toLowerCase() === 'yes'; + } const asNumber = Number(value); return Number.isNaN(asNumber) ? value : asNumber; } diff --git a/src/app/tasks/maker-checker-entity-fetch.ts b/src/app/tasks/maker-checker-entity-fetch.ts index a9d7845e2..90ed99559 100644 --- a/src/app/tasks/maker-checker-entity-fetch.ts +++ b/src/app/tasks/maker-checker-entity-fetch.ts @@ -7,6 +7,8 @@ */ import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { withExtensions } from 'app/products/loan-products/loan-product-original.util'; import { ProductsService } from 'app/products/products.service'; import { ClientsService } from 'app/clients/clients.service'; @@ -59,7 +61,7 @@ const ENTITY_FETCHERS: Record Ob SAVINGSPRODUCT: (id, s) => s.products.getSavingProduct(id, true), CURRENTPRODUCT: (id, s) => s.products.getCurrentProduct(id, true), SHAREPRODUCT: (id, s) => s.products.getShareProduct(id, true), - LOANPRODUCT: (id, s) => s.products.getLoanProduct(id, true), + LOANPRODUCT: (id, s) => s.products.getLoanProduct(id, true).pipe(map((product: any) => withExtensions(product))), FIXEDDEPOSITPRODUCT: (id, s) => s.products.getFixedDepositProductAndTemplate(id), RECURRINGDEPOSITPRODUCT: (id, s) => s.products.getRecurringDepositProduct(id, true), CHARGE: (id, s) => s.products.getCharge(id, true), diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 5cf0d7b56..e1c0fccd9 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '261012', - 'hash': '6b0f218b' + 'version': '261013', + 'hash': 'd88f2a6eb' }, 'allow_switching_backend_instance': true };