implemented the Edit product functionality for the RD,FD,DDS,LIC

This commit is contained in:
2026-10-16 10:32:18 +05:30
parent d88f2a6eb1
commit 127c2a7d01
49 changed files with 2694 additions and 539 deletions
@@ -7,6 +7,27 @@
-->
<form [formGroup]="loansAccountDetailsForm">
<div class="layout-row-wrap layout-lt-md-column gap-2percent">
<mat-form-field class="flex-48">
<mat-label>Category</mat-label>
<mat-select [value]="categoryFilter" (selectionChange)="categoryFilter = $event.value; onCategoryChange()">
<mat-option value="">All categories</mat-option>
@for (category of categories; track category.id) {
<mat-option [value]="category.id">{{ category.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>Product type</mat-label>
<mat-select [value]="typeFilter" (selectionChange)="typeFilter = $event.value; applyProductFilters()">
<mat-option value="">All types</mat-option>
@for (type of typeOptions; track type.id) {
<mat-option [value]="type.id">{{ type.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="layout-row-wrap gap-2px responsive-column">
<mat-form-field class="flex-fill">
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
@@ -35,6 +56,8 @@
</mat-form-field>
</div>
<mifosx-loan-product-template-card [product]="selectedProduct"></mifosx-loan-product-template-card>
@if (loanProductSelected) {
<div class="layout-row-wrap layout-lt-md-column gap-2percent">
<mat-form-field class="flex-48">
@@ -96,6 +96,15 @@
>
Edit · Submit
</button>
<button
type="button"
class="lirr__icon-btn lirr__icon-btn--danger"
title="Delete draft"
aria-label="Delete draft"
(click)="deleteDraft(r, $event)"
>
<i class="fas fa-trash"></i>
</button>
</ng-container>
<ng-container *ngSwitchCase="'Active'">
<button
@@ -522,3 +522,31 @@
color: #3730a3;
}
}
/* Icon-only action on a row (delete draft). */
.lirr__icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
margin-left: 8px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
color: #64748b;
cursor: pointer;
&:hover {
background: #f8fafc;
}
&--danger {
color: #dc2626;
border-color: #fecaca;
&:hover {
background: #fef2f2;
}
}
}
@@ -10,6 +10,8 @@ import { Component, OnInit, inject } from '@angular/core';
import { DatePipe } from '@angular/common';
import { Router } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatDialog } from '@angular/material/dialog';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { MatExpansionModule } from '@angular/material/expansion';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RateRegisterService } from './rate-register.service';
@@ -33,6 +35,7 @@ export class LoanInterestRateRegisterComponent implements OnInit {
private readonly service = inject(RateRegisterService);
private readonly router = inject(Router);
private readonly snack = inject(MatSnackBar);
private readonly dialog = inject(MatDialog);
registers: RateRegister[] = [];
active?: RateRegister;
@@ -71,6 +74,22 @@ export class LoanInterestRateRegisterComponent implements OnInit {
}
}
/** Deletes a draft (a version that was never submitted) after a confirmation. */
deleteDraft(register: RateRegister, event: Event): void {
event.stopPropagation();
const label = register.name || `draft from ${this.formatShort(register.effectiveFrom)}`;
this.dialog
.open(DeleteDialogComponent, { data: { deleteContext: label } })
.afterClosed()
.subscribe((response: any) => {
if (response?.delete) {
this.service.deleteDraft(register.id);
this.snack.open('Draft deleted', 'OK', { duration: 3000 });
this.reload();
}
});
}
/** Checker approves the pending register. The previously-Active one is deactivated. */
approve(register: RateRegister, event: Event): void {
event.stopPropagation();
@@ -52,6 +52,13 @@ export class RateRegisterService {
}
}
/** Throws a draft away. Only a local, never-submitted draft can go; server versions are never touched. */
deleteDraft(id: number): void {
this.localDrafts = this.localDrafts.filter((d) => !(d.id === id && d.status === 'Draft'));
this.cache = this.cache.filter((d) => !(d.id === id && d.status === 'Draft'));
this.saveDrafts();
}
/** Persist the current drafts (call after any edit the user should not lose). */
saveDrafts(): void {
try {
@@ -9,7 +9,9 @@
<div class="lp-container">
<div class="lp-header">
<i class="fas fa-hand-holding-usd lp-header__icon"></i>
<h2 class="lp-header__title">{{ 'labels.heading.Create Loan Product' | translate }}</h2>
<h2 class="lp-header__title">
{{ isEdit ? 'Edit Loan Product' : ('labels.heading.Create Loan Product' | translate) }}
</h2>
</div>
<mat-tab-group
@@ -30,7 +32,7 @@
</div>
</mat-tab>
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-coins lp-tab-icon"></i>
{{ 'labels.inputs.CURRENCY' | translate }}
@@ -43,7 +45,7 @@
</mat-tab>
@if (isAdvanceProduct) {
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-shield-alt lp-tab-icon"></i>
SECURITY &amp; LIEN
@@ -57,7 +59,7 @@
</div>
</mat-tab>
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-sliders-h lp-tab-icon"></i>
LIMIT &amp; TENURE
@@ -69,7 +71,7 @@
</div>
</mat-tab>
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-percent lp-tab-icon"></i>
INTEREST
@@ -82,19 +84,7 @@
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-triangle-exclamation lp-tab-icon"></i>
PENAL &amp; NPA
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-penal-npa-step
[loanProductsTemplate]="loanProductsTemplate"
></mifosx-loan-product-penal-npa-step>
</div>
</mat-tab>
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-arrows-rotate lp-tab-icon"></i>
RENEWAL &amp; CLOSURE
@@ -105,7 +95,7 @@
</mat-tab>
@if (isOverdraftFacility) {
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-money-check lp-tab-icon"></i>
CHEQUE BOOK
@@ -118,7 +108,7 @@
}
@if (isLoanAccount) {
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-shield-alt lp-tab-icon"></i>
SECURITY &amp; COLLATERAL
@@ -128,7 +118,7 @@
</div>
</mat-tab>
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-sliders-h lp-tab-icon"></i>
AMOUNT &amp; TENURE
@@ -138,7 +128,7 @@
</div>
</mat-tab>
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-calendar-alt lp-tab-icon"></i>
REPAYMENT &amp; EMI
@@ -148,7 +138,7 @@
</div>
</mat-tab>
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-percent lp-tab-icon"></i>
INTEREST
@@ -161,19 +151,7 @@
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-triangle-exclamation lp-tab-icon"></i>
PENAL &amp; NPA
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-penal-npa-step
[loanProductsTemplate]="loanProductsTemplate"
></mifosx-loan-product-penal-npa-step>
</div>
</mat-tab>
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-flag-checkered lp-tab-icon"></i>
PREPAYMENT &amp; CLOSURE
@@ -184,12 +162,27 @@
</mat-tab>
}
<!-- Common to both Advances A/C and Loans A/C (and visible even before a product type is chosen). -->
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-triangle-exclamation lp-tab-icon"></i>
PENAL &amp; NPA
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-penal-npa-step
[loanProductsTemplate]="loanProductsTemplate"
[productTypeGroup]="loanProductDetailsForm.get('productTypeGroup')"
[productTypeSubGroup]="loanProductDetailsForm.get('productTypeSubGroup')"
></mifosx-loan-product-penal-npa-step>
</div>
</mat-tab>
<!--
OLD LOANS A/C PATH — commented out for now (replaced by the Loans A/C steps above).
Settings, Interest Refund, Payment Allocation and Terms tabs are kept here so they can be restored.
@if (!isAdvanceProduct) {
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-cog lp-tab-icon"></i>
{{ 'labels.inputs.SETTINGS' | translate }}
@@ -206,7 +199,7 @@
}
@if (isAdvancedPaymentStrategy) {
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-undo lp-tab-icon"></i>
{{ 'labels.inputs.INTEREST REFUND' | translate }}
@@ -219,7 +212,7 @@
</div>
</mat-tab>
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-exchange-alt lp-tab-icon"></i>
{{ 'labels.inputs.PAYMENT ALLOCATION' | translate }}
@@ -240,7 +233,7 @@
}
@if (!isAdvanceProduct) {
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-sliders-h lp-tab-icon"></i>
{{ 'labels.inputs.TERMS' | translate }}
@@ -254,7 +247,7 @@
}
-->
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-receipt lp-tab-icon"></i>
{{ 'labels.inputs.CHARGES' | translate }}
@@ -270,7 +263,7 @@
<!-- OLD LOANS A/C PATH (Deferred Income Recognition) — commented out for now.
@if (isAdvancedPaymentStrategy) {
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-clock lp-tab-icon"></i>
{{ 'labels.inputs.DEFERRED INCOME RECOGNITION' | translate }}
@@ -291,7 +284,7 @@
}
-->
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-book lp-tab-icon"></i>
{{ '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"
></mifosx-loan-product-accounting-step>
</div>
</mat-tab>
@if (loanProductFormValid) {
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-eye lp-tab-icon"></i>
{{ 'labels.inputs.PREVIEW' | translate }}
@@ -316,7 +312,7 @@
<mifosx-loan-product-preview-step
[loanProductsTemplate]="loanProductsTemplate"
[accountingRuleData]="accountingRuleData"
[loanProduct]="loanProduct"
[loanProduct]="previewLoanProduct"
(submitEvent)="submit()"
></mifosx-loan-product-preview-step>
</div>
@@ -324,6 +320,25 @@
}
</mat-tab-group>
@if (hasProductType && !loanProductFormValid) {
<div class="lp-incomplete">
<i class="fas fa-circle-info"></i>
<div>
<strong>The Review tab appears once every tab is complete.</strong>
Still to finish:
@for (item of incompleteTabs; track item.tab; let last = $last) {
<b>{{ item.tab }}</b>
@if (item.fields.length > 0) {
({{ item.fields.join(', ') }})
}
@if (!last) {
,
}
}
</div>
</div>
}
<div class="lp-action-bar">
<button mat-raised-button [routerLink]="['../']" class="lp-action-bar__cancel">
{{ 'labels.buttons.Cancel' | translate }}
@@ -336,7 +351,12 @@
</button>
}
@if (selectedTabIndex < totalTabs - 1) {
<button mat-raised-button color="primary" (click)="selectedTabIndex = selectedTabIndex + 1">
<button
mat-raised-button
color="primary"
[disabled]="!hasProductType"
(click)="selectedTabIndex = selectedTabIndex + 1"
>
{{ 'labels.buttons.Next' | translate }}
<i class="fas fa-arrow-right" style="margin-left: 8px"></i>
</button>
@@ -344,7 +364,7 @@
@if (loanProductFormValid && selectedTabIndex === totalTabs - 1) {
<button mat-raised-button color="primary" (click)="submit()">
<i class="fas fa-check" style="margin-right: 8px"></i>
{{ 'labels.buttons.Submit' | translate }}
{{ isEdit ? 'Save changes' : ('labels.buttons.Submit' | translate) }}
</button>
}
</div>
@@ -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;
}
}
@@ -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<string, any>): 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) {
@@ -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;
}
@@ -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;
}
}
@@ -38,18 +38,12 @@
<div class="lpa-group">
<div class="lpa-group__header">
<h4>{{ section.title }}</h4>
<button
type="button"
class="lpa-add-other-btn"
(click)="addOtherGlMapping(section.otherMappingsControlName)"
>
+ Add Other
</button>
</div>
<div class="lpa-list">
@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 @@
}
}
</div>
<!-- Other GL Mappings -->
<div class="lpa-list" [formArrayName]="section.otherMappingsControlName">
@for (
otherRow of getOtherGlMappingsArray(section.otherMappingsControlName).controls;
track $index;
let i = $index
) {
<div class="lpa-other-row-wrap">
<div class="lpa-row lpa-row--other" [formGroupName]="i">
<div class="lpa-row__meta lpa-row__meta--other">
<span
class="lpa-pill"
[class.lpa-pill--green]="section.pillClass === 'green'"
[class.lpa-pill--amber]="section.pillClass === 'amber'"
[class.lpa-pill--blue]="section.pillClass === 'blue'"
>
{{ section.pillLabel }}
</span>
<input
class="lpa-row__name lpa-row__name--other-account"
formControlName="accountName"
placeholder="Enter account name..."
type="text"
/>
</div>
<div class="lpa-row__actions lpa-row__actions--other">
<mat-form-field class="lpa-row__other-field" appearance="outline" subscriptSizing="dynamic">
<mat-label>GL Code</mat-label>
<mat-select
[formControl]="getOtherGlMappingControl(section.otherMappingsControlName, i, 'glAccountId')"
>
@for (glAccount of section.otherAccountOptions; track glAccount.id) {
<mat-option [value]="glAccount.id">
{{ '(' + glAccount.glCode + ') ' + glAccount.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<button
type="button"
class="lpa-remove-btn lpa-remove-btn--side"
(click)="removeOtherGlMapping(section.otherMappingsControlName, i)"
>
<i class="fas fa-times"></i>
</button>
</div>
}
</div>
</div>
}
<!-- Advanced Accounting Rules Section -->
@if (loanProductAccountingForm.value.accountingRule >= 2 && loanProductAccountingForm.value.accountingRule <= 4) {
<div class="lpa-group">
<div class="lpa-group__header">
<h4>Advanced Accounting Rules</h4>
</div>
<div class="lpa-checkbox-row">
<mat-checkbox formControlName="advancedAccountingRules">{{
'labels.heading.Advanced Accounting Rules' | translate
}}</mat-checkbox>
</div>
@if (loanProductAccountingForm.value.advancedAccountingRules) {
<!-- Payment Fund Sources -->
<div class="lpa-subsection">
<div class="lpa-subsection-header">
<h4>{{ 'labels.heading.Configure Fund Sources for Payment Channels' | translate }}</h4>
<button
type="button"
mat-raised-button
color="primary"
class="lpa-add-btn"
(click)="add('PaymentFundSource', paymentChannelToFundSourceMappings)"
>
<fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Add' | translate }}
</button>
</div>
@if (paymentChannelToFundSourceMappings.value.length !== 0) {
<div class="lpa-table-wrapper">
<table mat-table [dataSource]="paymentChannelToFundSourceMappings.value">
<ng-container matColumnDef="paymentTypeId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Payment Type' | translate }}</th>
<td mat-cell *matCellDef="let paymentFundSource">
{{ paymentFundSource.paymentTypeId | find: paymentTypeData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="fundSourceAccountId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fund Source' | translate }}</th>
<td mat-cell *matCellDef="let paymentFundSource">
{{ paymentFundSource.fundSourceAccountId | find: assetAccountData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let paymentFundSource; let i = index">
<button
mat-icon-button
color="primary"
(click)="edit('PaymentFundSource', paymentChannelToFundSourceMappings, i)"
>
<fa-icon icon="edit"></fa-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="delete('PaymentFundSource', paymentChannelToFundSourceMappings, i)"
>
<fa-icon icon="trash"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="paymentFundSourceDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: paymentFundSourceDisplayedColumns"></tr>
</table>
</div>
}
</div>
<!-- Fees to Income -->
<div class="lpa-subsection">
<div class="lpa-subsection-header">
<h4>{{ 'labels.heading.Map Fees to Specific Income Accounts' | translate }}</h4>
<button
type="button"
mat-raised-button
color="primary"
class="lpa-add-btn"
(click)="add('FeesIncome', feeToIncomeAccountMappings)"
>
<fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Add' | translate }}
</button>
</div>
@if (feeToIncomeAccountMappings.value.length !== 0) {
<div class="lpa-table-wrapper">
<table mat-table [dataSource]="feeToIncomeAccountMappings.value">
<ng-container matColumnDef="chargeId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fees' | translate }}</th>
<td mat-cell *matCellDef="let feesIncome">
{{ feesIncome.chargeId | find: chargeData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="incomeAccountId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Income Account' | translate }}</th>
<td mat-cell *matCellDef="let feesIncome">
{{ feesIncome.incomeAccountId | find: incomeAndLiabilityAccountData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let feesIncome; let i = index">
<button
mat-icon-button
color="primary"
(click)="edit('FeesIncome', feeToIncomeAccountMappings, i)"
>
<fa-icon icon="edit"></fa-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="delete('FeesIncome', feeToIncomeAccountMappings, i)"
>
<fa-icon icon="trash"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="feesPenaltyIncomeDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: feesPenaltyIncomeDisplayedColumns"></tr>
</table>
</div>
}
</div>
<!-- Penalties to Income -->
<div class="lpa-subsection">
<div class="lpa-subsection-header">
<h4>{{ 'labels.heading.Map Penalties to Specific Income Accounts' | translate }}</h4>
<button
type="button"
mat-raised-button
color="primary"
class="lpa-add-btn"
(click)="add('PenaltyIncome', penaltyToIncomeAccountMappings)"
>
<fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Add' | translate }}
</button>
</div>
@if (penaltyToIncomeAccountMappings.value.length !== 0) {
<div class="lpa-table-wrapper">
<table mat-table [dataSource]="penaltyToIncomeAccountMappings.value">
<ng-container matColumnDef="chargeId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Penalty' | translate }}</th>
<td mat-cell *matCellDef="let penaltyIncome">
{{ penaltyIncome.chargeId | find: penaltyData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="incomeAccountId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Income Account' | translate }}</th>
<td mat-cell *matCellDef="let penaltyIncome">
{{ penaltyIncome.incomeAccountId | find: incomeAccountData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let penaltyIncome; let i = index">
<button
mat-icon-button
color="primary"
(click)="edit('PenaltyIncome', penaltyToIncomeAccountMappings, i)"
>
<fa-icon icon="edit"></fa-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="delete('PenaltyIncome', penaltyToIncomeAccountMappings, i)"
>
<fa-icon icon="trash"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="feesPenaltyIncomeDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: feesPenaltyIncomeDisplayedColumns"></tr>
</table>
</div>
}
</div>
<!-- Charge-off Reasons to Expense -->
<div class="lpa-subsection">
<div class="lpa-subsection-header">
<h4>{{ 'labels.heading.Map Charge-off reasons to Expense accounts' | translate }}</h4>
<button
type="button"
mat-raised-button
color="primary"
class="lpa-add-btn"
[disabled]="!allowAddChargeOffReasonExpense"
(click)="add('ChargeOffReasonExpense', chargeOffReasonToExpenseAccountMappings)"
>
<fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Add' | translate }}
</button>
</div>
@if (chargeOffReasonToExpenseAccountMappings.value.length !== 0) {
<div class="lpa-table-wrapper">
<table mat-table [dataSource]="chargeOffReasonToExpenseAccountMappings.value">
<ng-container matColumnDef="chargeOffReasonCodeValueId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Charge-off reason' | translate }}</th>
<td mat-cell *matCellDef="let chargeOffReasonExpense">
{{
chargeOffReasonExpense.chargeOffReasonCodeValueId
| find: chargeOffReasonOptions : 'id' : 'name'
}}
</td>
</ng-container>
<ng-container matColumnDef="expenseAccountId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Expense Account' | translate }}</th>
<td mat-cell *matCellDef="let chargeOffReasonExpense">
{{ chargeOffReasonExpense.expenseAccountId | find: expenseAccountData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let chargeOffReasonExpense; let i = index">
<button
mat-icon-button
color="primary"
(click)="edit('ChargeOffReasonExpense', chargeOffReasonToExpenseAccountMappings, i)"
>
<fa-icon icon="edit"></fa-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="delete('ChargeOffReasonExpense', chargeOffReasonToExpenseAccountMappings, i)"
>
<fa-icon icon="trash"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="chargeOffReasonExpenseDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: chargeOffReasonExpenseDisplayedColumns"></tr>
</table>
</div>
}
</div>
<!-- Advanced Mapping Rule Components -->
<mifosx-advanced-accounting-mapping-rule
class="lpa-full-width"
[textField]="'Classification'"
[formType]="'BuydownFeeClassificationToIncome'"
[formArray]="buydownfeeClassificationToIncomeAccountMappings"
[textHeading]="'Buydown Fee classifications to Income accounts'"
[incomeAccountData]="incomeAccountData"
[accountingMappingOptions]="buydownFeeClassificationOptions"
(formChangeEvent)="formChangeEvent($event)"
></mifosx-advanced-accounting-mapping-rule>
<mifosx-advanced-accounting-mapping-rule
class="lpa-full-width"
[textField]="'Classification'"
[formType]="'CapitalizedIncomeClassificationToIncome'"
[formArray]="capitalizedIncomeClassificationToIncomeAccountMappings"
[textHeading]="'Capitalized Income classifications to Income accounts'"
[incomeAccountData]="incomeAccountData"
[accountingMappingOptions]="capitalizedIncomeClassificationOptions"
(formChangeEvent)="formChangeEvent($event)"
></mifosx-advanced-accounting-mapping-rule>
<mifosx-advanced-accounting-mapping-rule
class="lpa-full-width"
[textField]="'WriteOff Reason'"
[formType]="'WriteOffReasonToExpense'"
[formArray]="writeOffReasonsToExpenseMappings"
[textHeading]="'WriteOff reasons to Expense accounts'"
[expenseAccountData]="expenseAccountData"
[accountingMappingOptions]="writeOffReasonOptions"
(formChangeEvent)="formChangeEvent($event)"
></mifosx-advanced-accounting-mapping-rule>
}
</div>
}
</div>
@@ -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<string>([
'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<string, number | string | null> = {
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
},
{
@@ -77,6 +77,8 @@
<input matInput type="number" formControlName="defaultTenure" [min]="tenureMin" [max]="tenureMax" />
@if (isFdTenureLocked) {
<mat-hint>Between 12 and 15</mat-hint>
}
@if (isFdTenureLocked) {
<mat-error>Must be between <strong>12 and 15</strong> months</mat-error>
}
</mat-form-field>
@@ -42,10 +42,6 @@
<mat-label>Max Books Outstanding</mat-label>
<input matInput type="number" formControlName="maxBooksOutstanding" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Reissue When Unused Leaves ≤</mat-label>
<input matInput type="number" formControlName="reissueWhenLeaves" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>First Cheque Book Free</mat-label>
<mat-select formControlName="firstBookFree">
@@ -66,10 +62,6 @@
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field adv-field--full" style="grid-column: span 2">
<mat-label>Cheque Print Description</mat-label>
<input matInput formControlName="chequePrintDescription" />
</mat-form-field>
</div>
</div>
@@ -62,11 +62,9 @@ export class LoanProductChequeBookStepComponent {
],
defaultBookSize: ['25'],
maxBooksOutstanding: [1],
reissueWhenLeaves: [5],
firstBookFree: ['YES'],
freeBooksPerYear: [1],
namePrinted: ['Account holder name'],
chequePrintDescription: [''],
ppsThreshold: [
50000,
[
@@ -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;
@@ -0,0 +1,60 @@
<!--
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/.
-->
<form [formGroup]="loanProductEligibilityDocsForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-user-check"></i>
<h3>Borrower Eligibility</h3>
</div>
<div class="ed-body">
<div class="ed-rules">
<label class="ed-rule">
<mat-checkbox formControlName="memberRequired"></mat-checkbox>
<span class="ed-rule__text">
<strong>Borrower must be a member</strong>
<small>Only members of the bank can take this product</small>
</span>
</label>
<label class="ed-rule">
<mat-checkbox formControlName="guarantorMustBeMember"></mat-checkbox>
<span class="ed-rule__text">
<strong>Guarantors must be members</strong>
<small>Applies when the product asks for guarantors</small>
</span>
</label>
</div>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-file-lines"></i>
<h3>Required Documents</h3>
<span class="ed-count">{{ selectedCount }} of {{ documentOptions.length }} selected</span>
</div>
<div class="ed-body">
@for (group of documentGroups; track group.title) {
<div class="ed-group">
<div class="ed-group__title"><i class="fas {{ group.icon }}"></i>{{ group.title }}</div>
<div class="ed-docs">
@for (doc of documentsOf(group); track doc.id) {
<label class="ed-doc" [class.ed-doc--on]="isSelected(doc.id)">
<mat-checkbox
[checked]="isSelected(doc.id)"
(change)="toggleDocument(doc.id, $event.checked)"
></mat-checkbox>
<span>{{ doc.label }}</span>
</label>
}
</div>
</div>
}
</div>
</div>
</form>
@@ -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<string, string[]> = {
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(',') };
}
}
@@ -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). */
@@ -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<string, SecurityProfile> = {
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);
@@ -7,13 +7,14 @@
-->
<form [formGroup]="loanProductPenalNpaForm">
<div class="adv-section">
<!-- 1. PENAL -->
<div class="adv-section pn-card pn-card--penal">
<div class="adv-section__header">
<i class="fas fa-percent"></i>
<h3>Penal Charges</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>Penal Charges Applicable</mat-label>
<mat-select formControlName="penalApplicable">
@for (opt of yesNoOptions; track opt) {
@@ -21,11 +22,11 @@
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>Penal Rate (% p.a.)</mat-label>
<input matInput type="number" formControlName="penalRate" min="0" max="100" step="0.01" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>Applied On</mat-label>
<mat-select formControlName="penalAppliedOn">
@for (opt of appliedOnOptions; track opt.id) {
@@ -33,7 +34,7 @@
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>Penal Grace Days</mat-label>
<input matInput type="number" formControlName="penalGraceDays" min="0" />
</mat-form-field>
@@ -45,13 +46,14 @@
</div>
</div>
<div class="adv-section">
<!-- 2. SMA -->
<div class="adv-section pn-card pn-card--sma">
<div class="adv-section__header">
<i class="fas fa-triangle-exclamation"></i>
<h3>Asset Classification (SMA / NPA)</h3>
<i class="fas fa-bell"></i>
<h3>SMA (Special Mention Account)</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>Delinquency Bucket (SMA)</mat-label>
<mat-select formControlName="delinquencyBucketId">
<mat-option [value]="''">— None —</mat-option>
@@ -61,11 +63,38 @@
</mat-select>
<mat-hint>SMA 0–30 / 31–60 / 61–90</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>SMA Counting Starts After (months)</mat-label>
<input matInput type="number" formControlName="smaStartAfterMonths" min="0" required />
<mat-hint>
{{ isAdvanceAccount ? 'Advances: from disbursement (default 9)' : 'Loans: first EMI due (default 1)' }}
</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>In-Arrears Tolerance (₹)</mat-label>
<input matInput type="number" formControlName="inArrearsTolerance" min="0" />
<mat-hint>Small balance ignored for arrears</mat-hint>
</mat-form-field>
</div>
<div class="pn-stages">
<span class="pn-stage pn-stage--0">SMA-0 · 1–30 days</span>
<span class="pn-stage pn-stage--1">SMA-1 · 31–60 days</span>
<span class="pn-stage pn-stage--2">SMA-2 · 61–90 days</span>
</div>
</div>
<!-- 3. NPA -->
<div class="adv-section pn-card pn-card--npa">
<div class="adv-section__header">
<i class="fas fa-triangle-exclamation"></i>
<h3>NPA (Non-Performing Asset)</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>NPA After (days overdue)</mat-label>
<input matInput type="number" formControlName="npaDays" min="0" required />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>NPA Trigger</mat-label>
<mat-select formControlName="npaTrigger">
@for (opt of npaTriggerOptions; track opt.id) {
@@ -73,7 +102,7 @@
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>Interest after NPA</mat-label>
<mat-select formControlName="interestAfterNpa">
@for (opt of interestAfterNpaOptions; track opt.id) {
@@ -81,16 +110,31 @@
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Grace on Arrears Ageing (days)</mat-label>
<input matInput type="number" formControlName="graceOnArrearsAgeing" min="0" />
<mat-hint>Days before an amount counts as overdue</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>In-Arrears Tolerance (₹)</mat-label>
<input matInput type="number" formControlName="inArrearsTolerance" min="0" />
<mat-hint>Small balance ignored for arrears</mat-hint>
</mat-form-field>
@if (isRevolvingSubType) {
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>NPA Out-of-Order Basis (CC/OD)</mat-label>
<mat-select formControlName="npaOutOfOrderBasis">
@for (opt of npaOutOfOrderBasisOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>Stock Statement Validity (days)</mat-label>
<input matInput type="number" formControlName="stockStatementValidityDays" min="0" />
<mat-hint>Older statement + drawings 90 days = NPA</mat-hint>
</mat-form-field>
}
@if (isKccSubType) {
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>Crop Duration (KCC)</mat-label>
<mat-select formControlName="cropDuration">
@for (opt of cropDurationOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
}
</div>
<div class="adv-subsection">
<div class="adv-checks">
@@ -103,4 +147,6 @@
</div>
</div>
</div>
<!-- 4. ECL (SICR & LGD): hidden for now. The form controls stay in the .ts so the card can be restored. -->
</form>
@@ -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<string, string> = {
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 };
}
}
@@ -6,12 +6,36 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<mifosx-loan-product-summary
[action]="'preview'"
[loanProduct]="loanProduct"
[loanProductsTemplate]="loanProductsTemplate"
[paymentAllocations]="loanProduct.paymentAllocation"
[creditAllocations]="loanProduct.creditAllocation"
[supportedInterestRefundTypes]="loanProduct.supportedInterestRefundTypes"
>
</mifosx-loan-product-summary>
<div class="rv">
<p class="rv__intro">
Check the details below. Use <strong>Submit</strong> at the bottom to send the product for approval.
</p>
@for (section of sections; track section.title) {
<div class="rv__card">
<div class="rv__head"><i class="fas {{ section.icon }}"></i>{{ section.title }}</div>
<div class="rv__grid">
@for (item of section.rows; track item.label) {
<div class="rv__row">
<span class="rv__label">{{ item.label }}</span>
<span class="rv__value">{{ item.value }}</span>
</div>
}
</div>
</div>
}
@if (otherSettings.length > 0) {
<details class="rv__card rv__other">
<summary class="rv__head"><i class="fas fa-sliders"></i>Other settings ({{ otherSettings.length }})</summary>
<div class="rv__grid">
@for (item of otherSettings; track item.label) {
<div class="rv__row">
<span class="rv__label">{{ item.label }}</span>
<span class="rv__value">{{ item.value }}</span>
</div>
}
</div>
</details>
}
</div>
@@ -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;
}
}
@@ -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<string>();
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<string>([
'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);
}
}
@@ -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<string, string> = {
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. */
@@ -45,6 +45,11 @@ export interface RegisterRateSelection {
const PARTICULAR_BY_SUB_TYPE: Record<string, string> = {
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',
@@ -81,9 +81,33 @@
placeholder="Search by name or short name…"
/>
</div>
<div class="pl-filters">
<label class="pl-filter">
<span>Category</span>
<select [(ngModel)]="categoryFilter" (ngModelChange)="onCategoryChange()">
<option value="">All categories</option>
@for (cat of categories; track cat.id) {
<option [value]="cat.id">{{ cat.label }}</option>
}
</select>
</label>
<label class="pl-filter">
<span>Product type</span>
<select [(ngModel)]="typeFilter" (ngModelChange)="refreshFilter()">
<option value="">All types</option>
@for (type of typeOptions; track type.id) {
<option [value]="type.id">{{ type.label }}</option>
}
</select>
</label>
<label class="pl-filter pl-filter--check">
<input type="checkbox" [(ngModel)]="activeOnly" (ngModelChange)="refreshFilter()" />
<span>Active only</span>
</label>
</div>
</div>
@if (totalCount === 0) {
@if (totalCount === 0 && pendingRows.length === 0) {
<div class="pl-empty">
<div class="pl-empty-ic"><fa-icon icon="hand-holding-usd"></fa-icon></div>
<h3>No loan products yet</h3>
@@ -123,6 +147,16 @@
</td>
</ng-container>
<ng-container matColumnDef="category">
<th mat-header-cell *matHeaderCellDef>Category</th>
<td mat-cell *matCellDef="let p">{{ categoryOf(p) }}</td>
</ng-container>
<ng-container matColumnDef="productType">
<th mat-header-cell *matHeaderCellDef>Product Type</th>
<td mat-cell *matCellDef="let p">{{ typeOf(p) }}</td>
</ng-container>
<ng-container matColumnDef="closeDate">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Expiry Date' | translate }}</th>
<td mat-cell *matCellDef="let p">
@@ -137,48 +171,100 @@
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let p">
<span
class="pl-status-pill"
[class.pl-status-pill--active]="p.status === 'loanProduct.active'"
[class.pl-status-pill--inactive]="p.status !== 'loanProduct.active'"
>
<fa-icon [icon]="p.status === 'loanProduct.active' ? 'check-circle' : 'times-circle'"></fa-icon>
{{
p.status === 'loanProduct.active'
? ('labels.inputs.Active' | translate)
: ('labels.catalogs.Inactive' | translate)
}}
</span>
@if (p.pending) {
<span
class="pl-status-pill pl-status-pill--pending"
matTooltip="Requested by {{ p.madeBy }}; visible as a product once a checker approves"
>
<fa-icon icon="hourglass-half"></fa-icon> Pending approval
</span>
} @else {
<span
class="pl-status-pill"
[class.pl-status-pill--active]="p.status === 'loanProduct.active'"
[class.pl-status-pill--inactive]="p.status !== 'loanProduct.active'"
>
<fa-icon [icon]="p.status === 'loanProduct.active' ? 'check-circle' : 'times-circle'"></fa-icon>
{{
p.status === 'loanProduct.active'
? ('labels.inputs.Active' | translate)
: ('labels.catalogs.Inactive' | translate)
}}
</span>
@if (pendingUpdates.get(p.id); as update) {
<button
type="button"
class="pl-status-pill pl-status-pill--pending"
style="margin-left: 6px; cursor: pointer"
matTooltip="An edit by {{ update.maker }} is waiting for a checker. Click to see what changes."
(click)="$event.stopPropagation(); viewUpdate(p)"
>
<fa-icon icon="hourglass-half"></fa-icon> Update pending: view changes
</button>
}
}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="num">Actions</th>
<td mat-cell *matCellDef="let p" class="num">
<button
type="button"
class="pl-action-btn pl-action-btn--view"
[routerLink]="[p.id]"
(click)="$event.stopPropagation()"
matTooltip="View product details"
>
<fa-icon icon="eye"></fa-icon><span>View</span>
</button>
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
[routerLink]="[p.id, 'edit']"
(click)="$event.stopPropagation()"
matTooltip="Edit this product"
*mifosxHasPermission="'UPDATE_LOANPRODUCT'"
>
<fa-icon icon="edit"></fa-icon><span>Edit</span>
</button>
@if (p.pending) {
<button
type="button"
class="pl-action-btn pl-action-btn--view"
(click)="$event.stopPropagation(); viewPending(p)"
matTooltip="View the submitted details"
>
<fa-icon icon="eye"></fa-icon><span>View</span>
</button>
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
disabled
(click)="$event.stopPropagation()"
matTooltip="Locked until the request is approved or rejected"
>
<fa-icon icon="edit"></fa-icon><span>Edit</span>
</button>
} @else {
<button
type="button"
class="pl-action-btn pl-action-btn--view"
[routerLink]="[p.id]"
(click)="$event.stopPropagation()"
matTooltip="View product details"
>
<fa-icon icon="eye"></fa-icon><span>View</span>
</button>
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
[routerLink]="[p.id, 'edit']"
[disabled]="pendingUpdates.has(p.id)"
(click)="$event.stopPropagation()"
[matTooltip]="
pendingUpdates.has(p.id)
? 'Locked until the pending edit is approved or rejected'
: 'Edit this product'
"
*mifosxHasPermission="'UPDATE_LOANPRODUCT'"
>
<fa-icon icon="edit"></fa-icon><span>Edit</span>
</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" [routerLink]="[row.id]" class="pl-row"></tr>
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
[routerLink]="row.pending ? null : [row.id]"
(click)="row.pending && viewPending(row)"
[class.pl-row--pending]="row.pending"
class="pl-row"
></tr>
</table>
@if (filteredCount === 0 && totalCount > 0) {
@@ -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;
}
@@ -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<number, any>();
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<any>;
@@ -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: () => {
@@ -0,0 +1,39 @@
<!--
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/.
-->
<h1 mat-dialog-title>{{ data.name }}</h1>
<mat-dialog-content>
<div class="ppv__banner">
<i class="fas fa-hourglass-half"></i>
<span>
Awaiting checker approval. Requested by <strong>{{ data.madeBy }}</strong
>. This product is not active yet and cannot be edited until the request is approved or rejected.
</span>
</div>
<div class="ppv__chips">
<span class="ppv__chip">{{ category }}</span>
<span class="ppv__chip">{{ type }}</span>
<span class="ppv__chip">Short name: {{ data.shortName }}</span>
</div>
<table class="ppv__table">
<tbody>
@for (field of fields; track field.label) {
<tr>
<th>{{ field.label }}</th>
<td>{{ field.value }}</td>
</tr>
}
</tbody>
</table>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button type="button" mat-dialog-close class="ppv__close">Close</button>
</mat-dialog-actions>
@@ -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;
}
@@ -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<any>(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);
}
}
@@ -48,6 +48,17 @@
<mat-label>{{ 'labels.inputs.Days Till' | translate }}</mat-label>
<input matInput type="number" required formControlName="maximumAgeDays" min="1" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Stage (RBI 2026 ECL)</mat-label>
<mat-select formControlName="stage">
<mat-option [value]="''">— None (informational only) —</mat-option>
<mat-option [value]="1">Stage 1 — Standard / performing</mat-option>
<mat-option [value]="2">Stage 2 — SICR / underperforming</mat-option>
<mat-option [value]="3">Stage 3 — NPA / credit-impaired</mat-option>
</mat-select>
<mat-hint>Drives staging for ECL provisioning; leave blank for a non-staging range.</mat-hint>
</mat-form-field>
</div>
</div>
@@ -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) => {
@@ -47,6 +47,17 @@
<mat-label>{{ 'labels.inputs.Days Till' | translate }}</mat-label>
<input matInput type="number" formControlName="maximumAgeDays" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Stage (RBI 2026 ECL)</mat-label>
<mat-select formControlName="stage">
<mat-option [value]="''">— None (informational only) —</mat-option>
<mat-option [value]="1">Stage 1 — Standard / performing</mat-option>
<mat-option [value]="2">Stage 2 — SICR / underperforming</mat-option>
<mat-option [value]="3">Stage 3 — NPA / credit-impaired</mat-option>
</mat-select>
<mat-hint>Drives staging for ECL provisioning; leave blank for a non-staging range.</mat-hint>
</mat-form-field>
</div>
</div>
@@ -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) => {
@@ -52,6 +52,25 @@
<span class="vr-label">{{ 'labels.inputs.Days Till' | translate }}</span>
<span class="vr-value">{{ delinquencyRangeData.maximumAgeDays }}</span>
</div>
<div class="vr-row">
<span class="vr-label">Stage (RBI 2026 ECL)</span>
<span class="vr-value">
@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) —
}
}
</span>
</div>
</div>
</div>
</div>
@@ -34,5 +34,16 @@
</div>
<i class="fas fa-chevron-right mdb-card__arrow"></i>
</div>
<div class="mdb-card" [routerLink]="['floor-master']" *mifosxHasPermission="'READ_PRUDENTIAL_FLOOR'">
<div class="mdb-card__icon-wrap">
<i class="fas fa-chart-line"></i>
</div>
<div class="mdb-card__content">
<h3>Prudential Floor Master</h3>
<p>RBI 2026 ECL stage-wise provisioning floor, by product category</p>
</div>
<i class="fas fa-chevron-right mdb-card__arrow"></i>
</div>
</div>
</div>
@@ -0,0 +1,64 @@
<!--
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/.
-->
<div class="container">
<div class="table-header">
<div class="table-title-group">
<h2 class="table-title">
<i class="fas fa-chart-line title-icon"></i>
Prudential Floor Master
</h2>
<p class="pf-subtitle">
RBI 2026 ECL framework (para 82) — regulatory minimum provisioning floor by product category and stage. Seeded
from the RBI Directions; read-only.
</p>
</div>
<div class="table-actions">
<div class="table-search">
<mat-form-field appearance="outline">
<i matPrefix class="fas fa-search"></i>
<mat-label>{{ 'labels.inputs.Filter' | translate }}</mat-label>
<input matInput (keyup)="applyFilter($event.target.value)" />
</mat-form-field>
</div>
</div>
</div>
<div class="table-container">
<table mat-table [dataSource]="dataSource" matSort class="modern-table">
<ng-container matColumnDef="productCategory">
<th mat-header-cell *matHeaderCellDef mat-sort-header="productCategory">Product Category</th>
<td mat-cell *matCellDef="let row">{{ row.productCategory }}</td>
</ng-container>
<ng-container matColumnDef="stage">
<th mat-header-cell *matHeaderCellDef mat-sort-header="stage">Stage</th>
<td mat-cell *matCellDef="let row">{{ stageLabels[row.stage] ?? row.stage }}</td>
</ng-container>
<ng-container matColumnDef="duration">
<th mat-header-cell *matHeaderCellDef>Duration in Stage 3</th>
<td mat-cell *matCellDef="let row">{{ durationLabel(row) }}</td>
</ng-container>
<ng-container matColumnDef="securedFloorPct">
<th mat-header-cell *matHeaderCellDef mat-sort-header="securedFloorPct">Secured Floor (%)</th>
<td mat-cell *matCellDef="let row">{{ row.securedFloorPct }}</td>
</ng-container>
<ng-container matColumnDef="unsecuredFloorPct">
<th mat-header-cell *matHeaderCellDef mat-sort-header="unsecuredFloorPct">Unsecured Floor (%)</th>
<td mat-cell *matCellDef="let row">{{ row.unsecuredFloorPct }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row"></tr>
</table>
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons></mat-paginator>
</div>
</div>
@@ -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<any>}
*/
resolve(): Observable<any> {
return this.productsService.getPrudentialFloors();
}
}
@@ -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;
}
@@ -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<any>;
/** 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<number, string> = {
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;
}
}
@@ -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,
+41 -2
View File
@@ -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<any>} Prudential Floor rows
*/
getPrudentialFloors(productCategory?: string): Observable<any> {
const params = productCategory ? { productCategory } : {};
return this.http.get('/prudentialfloors', { params });
}
/**
* @returns {Observable<any>} 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<any> {
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<any[]>('/makercheckers', { params, context })
.pipe(catchError(() => of([] as any[])));
const mine$ = this.http
.get<any[]>('/makercheckers/mine', { params, context })
.pipe(catchError(() => of([] as any[])));
return forkJoin([
toApprove$,
mine$
]).pipe(
map(
([
toApprove,
mine
]) => {
const byId = new Map<number, any>();
(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. */
@@ -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<string, string> = {
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 "<name>Type": {id, value}", not "<name>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<string, string>();
@@ -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)
}))
+4
View File
@@ -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;
}
+3 -1
View File
@@ -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<string, (id: string, s: EntityFetchServices) => 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),
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '261012',
'hash': '6b0f218b'
'version': '261013',
'hash': 'd88f2a6eb'
},
'allow_switching_backend_instance': true
};