Compare commits

..
Author SHA1 Message Date
niteshandClaude Opus 5.5 55f5c1cf32 feat(ui): longer startup splash and updated loading animation
The startup splash now holds for at least 4 seconds (progress 2% per tick up to 90%)
instead of 2, and the loading animation is updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:31:24 +05:30
niteshandClaude Opus 5.5 6507e68203 feat(cashier): FD is credit-only on the transaction entry, and a Bank Transfer can fund an FD
- A Fixed Deposit row is credit-only on the clerk and cashier screens (isFdCreditOnly):
  its DR button is disabled, and Save refuses any FD row left on DR (for example by a
  Withdrawal Slip TYPE). Money leaves an FD only through its maturity or premature
  closure; the clerk's "Close this account" flow is the one exception.
- Bank Transfer / Fund Transfer accepts an FD on the CR side, so an FD can be funded from
  the customer's own Savings or Current account. The server enforces the funding rules
  (first and only funding, exactly the principal, the source account's owner, freeze
  status and available balance).

Requires the backend change "FD funding rules and no FD withdrawals from clerk/cashier requests".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:31:03 +05:30
53 changed files with 625 additions and 2748 deletions
@@ -184,7 +184,7 @@
class="ws-f__ctl" class="ws-f__ctl"
[(ngModel)]="row.acTyp" [(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
(change)="isGlCreditOnly(row) && (row.dbCr = 'Cr')" (change)="isCreditOnly(row) && (row.dbCr = 'Cr')"
> >
@for (a of acTypOptionsFor(row); track a) { @for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option> <option [value]="a">{{ a }}</option>
@@ -267,7 +267,7 @@
type="button" type="button"
class="ws-seg__btn ws-seg__btn--dr" class="ws-seg__btn ws-seg__btn--dr"
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'" [class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
[disabled]="isGlCreditOnly(row)" [disabled]="isCreditOnly(row)"
(click)="row.dbCr = 'Dr'" (click)="row.dbCr = 'Dr'"
> >
DR DR
@@ -1644,7 +1644,7 @@
class="ws-f__ctl" class="ws-f__ctl"
[(ngModel)]="row.acTyp" [(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
(change)="isGlCreditOnly(row) && (row.dbCr = 'Cr')" (change)="isCreditOnly(row) && (row.dbCr = 'Cr')"
> >
@for (a of acTypOptionsFor(row); track a) { @for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option> <option [value]="a">{{ a }}</option>
@@ -1715,7 +1715,7 @@
type="button" type="button"
class="ws-seg__btn ws-seg__btn--dr" class="ws-seg__btn ws-seg__btn--dr"
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'" [class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
[disabled]="isGlCreditOnly(row)" [disabled]="isCreditOnly(row)"
(click)="$event.stopPropagation(); row.dbCr = 'Dr'" (click)="$event.stopPropagation(); row.dbCr = 'Dr'"
> >
DR DR
@@ -2344,6 +2344,20 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
); );
} }
/**
* A Fixed Deposit row is credit-only on both the clerk and the cashier screens: an FD is funded once, with
* exactly its principal, and money leaves it only through its maturity or premature closure. The one
* exception is the clerk's own "Close this account" flow, which raises a closure request.
*/
isFdCreditOnly(row: TxnRow): boolean {
return row.acTyp === 'FD' && !this.pendingClosure;
}
/** DR is not offered for this row (see isGlCreditOnly and isFdCreditOnly). */
isCreditOnly(row: TxnRow): boolean {
return this.isGlCreditOnly(row) || this.isFdCreditOnly(row);
}
/** Called when the TYPE dropdown changes — sets sensible defaults for TXN TP and DR/CR */ /** Called when the TYPE dropdown changes — sets sensible defaults for TXN TP and DR/CR */
/** /**
* A cashier row that was filled from an approved token (withdrawal / account-closure) is cash * A cashier row that was filled from an approved token (withdrawal / account-closure) is cash
@@ -5272,6 +5286,20 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
} }
onSave(): void { onSave(): void {
// Money never leaves a Fixed Deposit from this screen, clerk or cashier: no withdrawal slip, payment,
// collection or transfer debits an FD. It is paid out only through its maturity or premature closure
// (the clerk's "Close this account" flow). The DR button is already disabled for FD rows (isFdCreditOnly);
// this also catches a row set to DR by its TYPE, such as a Withdrawal Slip.
const fdDebitRows = this.rows.filter((r) => this.isFdCreditOnly(r) && r.dbCr === 'Dr');
if (fdDebitRows.length > 0) {
this.showAlert('error', 'Fixed Deposit Cannot Be Debited', [
`FD account(s) ${fdDebitRows.map((r) => r.acNo).join(', ')} cannot be debited or withdrawn from.`,
'Money leaves a Fixed Deposit only through its maturity or premature closure.',
'An FD can only be credited — once, with exactly its principal.'
]);
return;
}
// Demand drafts commit here like everything else on the worksheet. Checked first because a DD // Demand drafts commit here like everything else on the worksheet. Checked first because a DD
// row shares its shape with the ordinary entries below — without this it would fall through and // row shares its shape with the ordinary entries below — without this it would fall through and
// post as a plain transaction, with the draft never raised and the charges silently dropped. // post as a plain transaction, with the draft never raised and the charges silently dropped.
@@ -5349,7 +5377,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return; return;
} }
// Bank transfer: only savings (SD) or current (CA/CB) accounts allowed // Bank transfer: savings (SD) or current (CA/CB) accounts on either side, plus a Fixed Deposit (FD) on
// the CR side only -- funding it from the customer's own account. The server checks the funding rules
// (first and only funding, exactly the principal, the source account's owner and available balance).
const allowedTypes = new Set([ const allowedTypes = new Set([
'SD', 'SD',
'CA', 'CA',
@@ -5357,13 +5387,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
]); ]);
const invalidAccounts = [ const invalidAccounts = [
...drRows, ...drRows,
...crRows ...crRows.filter((r) => r.acTyp !== 'FD')
].filter((r) => !allowedTypes.has(r.acTyp)); ].filter((r) => !allowedTypes.has(r.acTyp));
if (invalidAccounts.length > 0) { if (invalidAccounts.length > 0) {
this.showAlert('error', 'Invalid Account Type', [ this.showAlert('error', 'Invalid Account Type', [
`Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`, `Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`,
'Only Savings (SD) and Current (CA) accounts are permitted.', 'Debit side: only Savings (SD) and Current (CA) accounts. Credit side: SD, CA or a Fixed Deposit (FD).',
'GL, FD and SHR accounts are not allowed — use Disbursement/Collection instead.', 'GL and SHR accounts are not allowed — use Disbursement/Collection instead.',
'RD installments are paid through a cashier Receipt or Auto Debit.' 'RD installments are paid through a cashier Receipt or Auto Debit.'
]); ]);
return; return;
@@ -7,27 +7,6 @@
--> -->
<form [formGroup]="loansAccountDetailsForm"> <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"> <div class="layout-row-wrap gap-2px responsive-column">
<mat-form-field class="flex-fill"> <mat-form-field class="flex-fill">
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
@@ -56,8 +35,6 @@
</mat-form-field> </mat-form-field>
</div> </div>
<mifosx-loan-product-template-card [product]="selectedProduct"></mifosx-loan-product-template-card>
@if (loanProductSelected) { @if (loanProductSelected) {
<div class="layout-row-wrap layout-lt-md-column gap-2percent"> <div class="layout-row-wrap layout-lt-md-column gap-2percent">
<mat-form-field class="flex-48"> <mat-form-field class="flex-48">
@@ -96,15 +96,6 @@
> >
Edit · Submit Edit · Submit
</button> </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>
<ng-container *ngSwitchCase="'Active'"> <ng-container *ngSwitchCase="'Active'">
<button <button
@@ -522,31 +522,3 @@
color: #3730a3; 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,8 +10,6 @@ import { Component, OnInit, inject } from '@angular/core';
import { DatePipe } from '@angular/common'; import { DatePipe } from '@angular/common';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar'; 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 { MatExpansionModule } from '@angular/material/expansion';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RateRegisterService } from './rate-register.service'; import { RateRegisterService } from './rate-register.service';
@@ -35,7 +33,6 @@ export class LoanInterestRateRegisterComponent implements OnInit {
private readonly service = inject(RateRegisterService); private readonly service = inject(RateRegisterService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly snack = inject(MatSnackBar); private readonly snack = inject(MatSnackBar);
private readonly dialog = inject(MatDialog);
registers: RateRegister[] = []; registers: RateRegister[] = [];
active?: RateRegister; active?: RateRegister;
@@ -74,22 +71,6 @@ 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. */ /** Checker approves the pending register. The previously-Active one is deactivated. */
approve(register: RateRegister, event: Event): void { approve(register: RateRegister, event: Event): void {
event.stopPropagation(); event.stopPropagation();
@@ -52,13 +52,6 @@ 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). */ /** Persist the current drafts (call after any edit the user should not lose). */
saveDrafts(): void { saveDrafts(): void {
try { try {
@@ -9,9 +9,7 @@
<div class="lp-container"> <div class="lp-container">
<div class="lp-header"> <div class="lp-header">
<i class="fas fa-hand-holding-usd lp-header__icon"></i> <i class="fas fa-hand-holding-usd lp-header__icon"></i>
<h2 class="lp-header__title"> <h2 class="lp-header__title">{{ 'labels.heading.Create Loan Product' | translate }}</h2>
{{ isEdit ? 'Edit Loan Product' : ('labels.heading.Create Loan Product' | translate) }}
</h2>
</div> </div>
<mat-tab-group <mat-tab-group
@@ -32,7 +30,7 @@
</div> </div>
</mat-tab> </mat-tab>
<mat-tab [disabled]="!hasProductType"> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
<i class="fas fa-coins lp-tab-icon"></i> <i class="fas fa-coins lp-tab-icon"></i>
{{ 'labels.inputs.CURRENCY' | translate }} {{ 'labels.inputs.CURRENCY' | translate }}
@@ -45,7 +43,7 @@
</mat-tab> </mat-tab>
@if (isAdvanceProduct) { @if (isAdvanceProduct) {
<mat-tab [disabled]="!hasProductType"> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
<i class="fas fa-shield-alt lp-tab-icon"></i> <i class="fas fa-shield-alt lp-tab-icon"></i>
SECURITY &amp; LIEN SECURITY &amp; LIEN
@@ -59,7 +57,7 @@
</div> </div>
</mat-tab> </mat-tab>
<mat-tab [disabled]="!hasProductType"> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
<i class="fas fa-sliders-h lp-tab-icon"></i> <i class="fas fa-sliders-h lp-tab-icon"></i>
LIMIT &amp; TENURE LIMIT &amp; TENURE
@@ -71,7 +69,7 @@
</div> </div>
</mat-tab> </mat-tab>
<mat-tab [disabled]="!hasProductType"> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
<i class="fas fa-percent lp-tab-icon"></i> <i class="fas fa-percent lp-tab-icon"></i>
INTEREST INTEREST
@@ -84,7 +82,19 @@
</div> </div>
</mat-tab> </mat-tab>
<mat-tab [disabled]="!hasProductType"> <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>
<ng-template mat-tab-label> <ng-template mat-tab-label>
<i class="fas fa-arrows-rotate lp-tab-icon"></i> <i class="fas fa-arrows-rotate lp-tab-icon"></i>
RENEWAL &amp; CLOSURE RENEWAL &amp; CLOSURE
@@ -95,7 +105,7 @@
</mat-tab> </mat-tab>
@if (isOverdraftFacility) { @if (isOverdraftFacility) {
<mat-tab [disabled]="!hasProductType"> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
<i class="fas fa-money-check lp-tab-icon"></i> <i class="fas fa-money-check lp-tab-icon"></i>
CHEQUE BOOK CHEQUE BOOK
@@ -108,7 +118,7 @@
} }
@if (isLoanAccount) { @if (isLoanAccount) {
<mat-tab [disabled]="!hasProductType"> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
<i class="fas fa-shield-alt lp-tab-icon"></i> <i class="fas fa-shield-alt lp-tab-icon"></i>
SECURITY &amp; COLLATERAL SECURITY &amp; COLLATERAL
@@ -118,7 +128,7 @@
</div> </div>
</mat-tab> </mat-tab>
<mat-tab [disabled]="!hasProductType"> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
<i class="fas fa-sliders-h lp-tab-icon"></i> <i class="fas fa-sliders-h lp-tab-icon"></i>
AMOUNT &amp; TENURE AMOUNT &amp; TENURE
@@ -128,7 +138,7 @@
</div> </div>
</mat-tab> </mat-tab>
<mat-tab [disabled]="!hasProductType"> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
<i class="fas fa-calendar-alt lp-tab-icon"></i> <i class="fas fa-calendar-alt lp-tab-icon"></i>
REPAYMENT &amp; EMI REPAYMENT &amp; EMI
@@ -138,7 +148,7 @@
</div> </div>
</mat-tab> </mat-tab>
<mat-tab [disabled]="!hasProductType"> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
<i class="fas fa-percent lp-tab-icon"></i> <i class="fas fa-percent lp-tab-icon"></i>
INTEREST INTEREST
@@ -151,7 +161,19 @@
</div> </div>
</mat-tab> </mat-tab>
<mat-tab [disabled]="!hasProductType"> <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>
<ng-template mat-tab-label> <ng-template mat-tab-label>
<i class="fas fa-flag-checkered lp-tab-icon"></i> <i class="fas fa-flag-checkered lp-tab-icon"></i>
PREPAYMENT &amp; CLOSURE PREPAYMENT &amp; CLOSURE
@@ -162,27 +184,12 @@
</mat-tab> </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). 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. Settings, Interest Refund, Payment Allocation and Terms tabs are kept here so they can be restored.
@if (!isAdvanceProduct) { @if (!isAdvanceProduct) {
<mat-tab [disabled]="!hasProductType"> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
<i class="fas fa-cog lp-tab-icon"></i> <i class="fas fa-cog lp-tab-icon"></i>
{{ 'labels.inputs.SETTINGS' | translate }} {{ 'labels.inputs.SETTINGS' | translate }}
@@ -199,7 +206,7 @@
} }
@if (isAdvancedPaymentStrategy) { @if (isAdvancedPaymentStrategy) {
<mat-tab [disabled]="!hasProductType"> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
<i class="fas fa-undo lp-tab-icon"></i> <i class="fas fa-undo lp-tab-icon"></i>
{{ 'labels.inputs.INTEREST REFUND' | translate }} {{ 'labels.inputs.INTEREST REFUND' | translate }}
@@ -212,7 +219,7 @@
</div> </div>
</mat-tab> </mat-tab>
<mat-tab [disabled]="!hasProductType"> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
<i class="fas fa-exchange-alt lp-tab-icon"></i> <i class="fas fa-exchange-alt lp-tab-icon"></i>
{{ 'labels.inputs.PAYMENT ALLOCATION' | translate }} {{ 'labels.inputs.PAYMENT ALLOCATION' | translate }}
@@ -233,7 +240,7 @@
} }
@if (!isAdvanceProduct) { @if (!isAdvanceProduct) {
<mat-tab [disabled]="!hasProductType"> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
<i class="fas fa-sliders-h lp-tab-icon"></i> <i class="fas fa-sliders-h lp-tab-icon"></i>
{{ 'labels.inputs.TERMS' | translate }} {{ 'labels.inputs.TERMS' | translate }}
@@ -247,7 +254,7 @@
} }
--> -->
<mat-tab [disabled]="!hasProductType"> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
<i class="fas fa-receipt lp-tab-icon"></i> <i class="fas fa-receipt lp-tab-icon"></i>
{{ 'labels.inputs.CHARGES' | translate }} {{ 'labels.inputs.CHARGES' | translate }}
@@ -263,7 +270,7 @@
<!-- OLD LOANS A/C PATH (Deferred Income Recognition) — commented out for now. <!-- OLD LOANS A/C PATH (Deferred Income Recognition) — commented out for now.
@if (isAdvancedPaymentStrategy) { @if (isAdvancedPaymentStrategy) {
<mat-tab [disabled]="!hasProductType"> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
<i class="fas fa-clock lp-tab-icon"></i> <i class="fas fa-clock lp-tab-icon"></i>
{{ 'labels.inputs.DEFERRED INCOME RECOGNITION' | translate }} {{ 'labels.inputs.DEFERRED INCOME RECOGNITION' | translate }}
@@ -284,7 +291,7 @@
} }
--> -->
<mat-tab [disabled]="!hasProductType"> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
<i class="fas fa-book lp-tab-icon"></i> <i class="fas fa-book lp-tab-icon"></i>
{{ 'labels.inputs.ACCOUNTING' | translate }} {{ 'labels.inputs.ACCOUNTING' | translate }}
@@ -295,15 +302,12 @@
[accountingRuleData]="accountingRuleData" [accountingRuleData]="accountingRuleData"
[loanProductFormValid]="loanProductFormValid" [loanProductFormValid]="loanProductFormValid"
[deferredIncomeRecognition]="deferredIncomeRecognition" [deferredIncomeRecognition]="deferredIncomeRecognition"
[productTypeGroup]="loanProductDetailsForm.get('productTypeGroup')?.value"
[productTypeSubGroup]="loanProductDetailsForm.get('productTypeSubGroup')?.value"
[productGlAccountId]="productGlAccount"
></mifosx-loan-product-accounting-step> ></mifosx-loan-product-accounting-step>
</div> </div>
</mat-tab> </mat-tab>
@if (loanProductFormValid) { @if (loanProductFormValid) {
<mat-tab [disabled]="!hasProductType"> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
<i class="fas fa-eye lp-tab-icon"></i> <i class="fas fa-eye lp-tab-icon"></i>
{{ 'labels.inputs.PREVIEW' | translate }} {{ 'labels.inputs.PREVIEW' | translate }}
@@ -312,7 +316,7 @@
<mifosx-loan-product-preview-step <mifosx-loan-product-preview-step
[loanProductsTemplate]="loanProductsTemplate" [loanProductsTemplate]="loanProductsTemplate"
[accountingRuleData]="accountingRuleData" [accountingRuleData]="accountingRuleData"
[loanProduct]="previewLoanProduct" [loanProduct]="loanProduct"
(submitEvent)="submit()" (submitEvent)="submit()"
></mifosx-loan-product-preview-step> ></mifosx-loan-product-preview-step>
</div> </div>
@@ -320,25 +324,6 @@
} }
</mat-tab-group> </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"> <div class="lp-action-bar">
<button mat-raised-button [routerLink]="['../']" class="lp-action-bar__cancel"> <button mat-raised-button [routerLink]="['../']" class="lp-action-bar__cancel">
{{ 'labels.buttons.Cancel' | translate }} {{ 'labels.buttons.Cancel' | translate }}
@@ -351,12 +336,7 @@
</button> </button>
} }
@if (selectedTabIndex < totalTabs - 1) { @if (selectedTabIndex < totalTabs - 1) {
<button <button mat-raised-button color="primary" (click)="selectedTabIndex = selectedTabIndex + 1">
mat-raised-button
color="primary"
[disabled]="!hasProductType"
(click)="selectedTabIndex = selectedTabIndex + 1"
>
{{ 'labels.buttons.Next' | translate }} {{ 'labels.buttons.Next' | translate }}
<i class="fas fa-arrow-right" style="margin-left: 8px"></i> <i class="fas fa-arrow-right" style="margin-left: 8px"></i>
</button> </button>
@@ -364,7 +344,7 @@
@if (loanProductFormValid && selectedTabIndex === totalTabs - 1) { @if (loanProductFormValid && selectedTabIndex === totalTabs - 1) {
<button mat-raised-button color="primary" (click)="submit()"> <button mat-raised-button color="primary" (click)="submit()">
<i class="fas fa-check" style="margin-right: 8px"></i> <i class="fas fa-check" style="margin-right: 8px"></i>
{{ isEdit ? 'Save changes' : ('labels.buttons.Submit' | translate) }} {{ 'labels.buttons.Submit' | translate }}
</button> </button>
} }
</div> </div>
@@ -90,27 +90,3 @@ $primary: #1353a0;
gap: 12px; 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 */ /** Angular Imports */
import { AfterViewInit, Component, OnInit, ViewChild, inject } from '@angular/core'; import { Component, OnInit, ViewChild, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
/** Custom Components */ /** Custom Components */
@@ -84,7 +84,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
LoanProductPreviewStepComponent LoanProductPreviewStepComponent
] ]
}) })
export class CreateLoanProductComponent implements OnInit, AfterViewInit { export class CreateLoanProductComponent implements OnInit {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private productsService = inject(ProductsService); private productsService = inject(ProductsService);
private alertService = inject(AlertService); private alertService = inject(AlertService);
@@ -135,8 +135,6 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
loanProductAccountingStep: LoanProductAccountingStepComponent; loanProductAccountingStep: LoanProductAccountingStepComponent;
loanProductsTemplate: any; loanProductsTemplate: any;
/** True when this wizard edits a saved product (the edit route) instead of creating one. */
isEdit = false;
accountingRuleData: string[] = []; accountingRuleData: string[] = [];
itemsByDefault: any[] = []; itemsByDefault: any[] = [];
selectedTabIndex = 0; selectedTabIndex = 0;
@@ -160,194 +158,22 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
constructor() { constructor() {
const loanProducts = this.loanProducts; const loanProducts = this.loanProducts;
this.route.data.subscribe( this.route.data.subscribe((data: { loanProductsTemplate: any; configurations: any }) => {
(data: { loanProductsTemplate: any; loanProductAndTemplate: any; configurations: any }) => { this.loanProductsTemplate = data.loanProductsTemplate;
// The edit route resolves the saved product together with the template options. const assetAccountData = this.loanProductsTemplate.accountingMappingOptions.assetAccountOptions || [];
this.isEdit = !!data.loanProductAndTemplate; const liabilityAccountData = this.loanProductsTemplate.accountingMappingOptions.liabilityAccountOptions || [];
this.loanProductsTemplate = data.loanProductAndTemplate ?? data.loanProductsTemplate; this.loanProductsTemplate.accountingMappingOptions.assetAndLiabilityAccountOptions =
const assetAccountData = this.loanProductsTemplate.accountingMappingOptions.assetAccountOptions || []; assetAccountData.concat(liabilityAccountData);
const liabilityAccountData = this.loanProductsTemplate.accountingMappingOptions.liabilityAccountOptions || [];
this.loanProductsTemplate.accountingMappingOptions.assetAndLiabilityAccountOptions =
assetAccountData.concat(liabilityAccountData);
this.itemsByDefault = loanProducts.setItemsByDefault(data.configurations); this.itemsByDefault = loanProducts.setItemsByDefault(data.configurations);
this.loanProductsTemplate['itemsByDefault'] = this.itemsByDefault; this.loanProductsTemplate['itemsByDefault'] = this.itemsByDefault;
if (!this.isEdit) { this.loanProductsTemplate = loanProducts.updateLoanProductDefaults(this.loanProductsTemplate, false);
this.loanProductsTemplate = loanProducts.updateLoanProductDefaults(this.loanProductsTemplate, false); });
}
}
);
} }
ngOnInit() { ngOnInit() {
this.accountingRuleData = this.accounting.getAccountingRulesForLoans(); this.accountingRuleData = this.accounting.getAccountingRulesForLoans();
this.buildAdvancedPaymentAllocation(); 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() { get loanProductDetailsForm() {
@@ -479,12 +305,6 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
return this.loanProductAccountingStep.loanProductAccountingForm; 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. */ /** The Details step's chosen product type; drives the Advances-only tabs. */
get isAdvanceProduct(): boolean { get isAdvanceProduct(): boolean {
return this.loanProductDetailsForm?.get('productTypeGroup')?.value === 'advances'; return this.loanProductDetailsForm?.get('productTypeGroup')?.value === 'advances';
@@ -496,14 +316,14 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
} }
get totalTabs(): number { get totalTabs(): number {
let tabs = 5; // details, currency, penal & npa, charges, accounting (always) let tabs = 4; // details, currency, charges, accounting (always)
if (this.isAdvanceProduct) { if (this.isAdvanceProduct) {
// security & lien, limit & tenure, interest, renewal & closure // security & lien, limit & tenure, interest, penal & npa, renewal & closure
tabs += 4; tabs += 5;
if (this.isOverdraftFacility) tabs += 1; // cheque book if (this.isOverdraftFacility) tabs += 1; // cheque book
} else if (this.isLoanAccount) { } else if (this.isLoanAccount) {
// security & collateral, amount & tenure, repayment & emi, interest, prepayment & closure // security & collateral, amount & tenure, repayment & emi, interest, penal & npa, prepayment & closure
tabs += 5; tabs += 6;
} }
// Old Loans A/C path (Settings, Terms, Interest Refund, Payment Allocation, Deferred Income) — commented out: // Old Loans A/C path (Settings, Terms, Interest Refund, Payment Allocation, Deferred Income) — commented out:
// else { // else {
@@ -514,114 +334,9 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
return tabs; 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() { get loanProductFormValid() {
const base = const base =
this.loanProductDetailsForm.valid && this.loanProductDetailsForm.valid && this.loanProductCurrencyForm.valid && this.loanProductAccountingForm.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. // Advances A/C: the curated Figma steps gate the form; Settings/Terms tabs are hidden.
if (this.isAdvanceProduct) { if (this.isAdvanceProduct) {
@@ -630,6 +345,7 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
!!this.loanProductSecurityLienStep?.loanProductSecurityLienForm?.valid && !!this.loanProductSecurityLienStep?.loanProductSecurityLienForm?.valid &&
!!this.loanProductAdvanceLimitTenureStep?.loanProductAdvanceLimitTenureForm?.valid && !!this.loanProductAdvanceLimitTenureStep?.loanProductAdvanceLimitTenureForm?.valid &&
!!this.loanProductAdvanceInterestStep?.loanProductAdvanceInterestForm?.valid && !!this.loanProductAdvanceInterestStep?.loanProductAdvanceInterestForm?.valid &&
!!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid &&
!!this.loanProductRenewalClosureStep?.loanProductRenewalClosureForm?.valid && !!this.loanProductRenewalClosureStep?.loanProductRenewalClosureForm?.valid &&
(!this.isOverdraftFacility || !!this.loanProductChequeBookStep?.loanProductChequeBookForm?.valid) (!this.isOverdraftFacility || !!this.loanProductChequeBookStep?.loanProductChequeBookForm?.valid)
); );
@@ -643,6 +359,7 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
!!this.loanProductLoanAmountTenureStep?.loanProductLoanAmountTenureForm?.valid && !!this.loanProductLoanAmountTenureStep?.loanProductLoanAmountTenureForm?.valid &&
!!this.loanProductLoanRepaymentStep?.loanProductLoanRepaymentForm?.valid && !!this.loanProductLoanRepaymentStep?.loanProductLoanRepaymentForm?.valid &&
!!this.loanProductLoanInterestStep?.loanProductLoanInterestForm?.valid && !!this.loanProductLoanInterestStep?.loanProductLoanInterestForm?.valid &&
!!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid &&
!!this.loanProductLoanPrepaymentStep?.loanProductLoanPrepaymentForm?.valid !!this.loanProductLoanPrepaymentStep?.loanProductLoanPrepaymentForm?.valid
); );
} }
@@ -658,33 +375,6 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
// return base && loansValid; // 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() { get loanProduct() {
// Advances A/C builds its own payload: the curated steps map onto Fineract's mandatory core fields // 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. // (principal, tenure, rate, amortization defaults) plus the whitelisted satellite params.
@@ -845,15 +535,9 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
penalAppliedOn: pn.penalAppliedOn, penalAppliedOn: pn.penalAppliedOn,
penalGraceDays: num(pn.penalGraceDays), penalGraceDays: num(pn.penalGraceDays),
npaTrigger: pn.npaTrigger, npaTrigger: pn.npaTrigger,
smaStartAfterMonths: num(pn.smaStartAfterMonths),
interestAfterNpa: pn.interestAfterNpa, interestAfterNpa: pn.interestAfterNpa,
npaUpgradeOnlyAllArrears: pn.upgradeOnlyAllArrears, npaUpgradeOnlyAllArrears: pn.upgradeOnlyAllArrears,
npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers, 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, securityMandatory: sec.securityMandatory,
guarantorsRequired: num(sec.guarantorsRequired), guarantorsRequired: num(sec.guarantorsRequired),
coApplicant: sec.coApplicant, coApplicant: sec.coApplicant,
@@ -1018,14 +702,7 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
penalAppliedOn: pn.penalAppliedOn, penalAppliedOn: pn.penalAppliedOn,
penalGraceDays: num(pn.penalGraceDays), penalGraceDays: num(pn.penalGraceDays),
npaTrigger: pn.npaTrigger, npaTrigger: pn.npaTrigger,
smaStartAfterMonths: num(pn.smaStartAfterMonths),
stockStatementValidityDays: num(pn.stockStatementValidityDays),
interestAfterNpa: pn.interestAfterNpa, 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, renewalAllowed: rc.renewalAllowed,
renewalPeriodMonths: num(rc.renewalPeriodMonths), renewalPeriodMonths: num(rc.renewalPeriodMonths),
renewalGraceDays: num(rc.renewalGraceDays), renewalGraceDays: num(rc.renewalGraceDays),
@@ -1063,9 +740,11 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
// cheque book full config // cheque book full config
chequeBookSizes: (cb?.bookSizes ?? []).join('|'), chequeBookSizes: (cb?.bookSizes ?? []).join('|'),
chequeMaxBooksOutstanding: num(cb?.maxBooksOutstanding), chequeMaxBooksOutstanding: num(cb?.maxBooksOutstanding),
chequeReissueWhenLeaves: num(cb?.reissueWhenLeaves),
chequeFirstBookFree: cb?.firstBookFree, chequeFirstBookFree: cb?.firstBookFree,
chequeFreeBooksPerYear: num(cb?.freeBooksPerYear), chequeFreeBooksPerYear: num(cb?.freeBooksPerYear),
chequeNamePrinted: cb?.namePrinted, chequeNamePrinted: cb?.namePrinted,
chequePrintDescription: cb?.chequePrintDescription,
chequeStopPaymentAllowed: cb?.stopPaymentAllowed, chequeStopPaymentAllowed: cb?.stopPaymentAllowed,
chequeMaintainStockRegister: cb?.maintainStockRegister, chequeMaintainStockRegister: cb?.maintainStockRegister,
chequeAllowWithdrawalSlip: cb?.allowWithdrawalSlip, chequeAllowWithdrawalSlip: cb?.allowWithdrawalSlip,
@@ -1092,19 +771,6 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
delete loanProduct['daysInYearCustomStrategy']; 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({ this.productsService.createLoanProduct(loanProduct).subscribe({
next: (response: any) => { next: (response: any) => {
if (response?.rollbackTransaction) { if (response?.rollbackTransaction) {
@@ -1,26 +0,0 @@
/**
* 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,242 +214,3 @@ $gray-900: #202124;
grid-template-columns: 1fr; 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,12 +38,18 @@
<div class="lpa-group"> <div class="lpa-group">
<div class="lpa-group__header"> <div class="lpa-group__header">
<h4>{{ section.title }}</h4> <h4>{{ section.title }}</h4>
<button
type="button"
class="lpa-add-other-btn"
(click)="addOtherGlMapping(section.otherMappingsControlName)"
>
+ Add Other
</button>
</div> </div>
<div class="lpa-list"> <div class="lpa-list">
@for (row of section.rows; track row.key) { @for (row of section.rows; track row.key) {
@if ( @if (
basicControls.has(row.controlName) && (!row.accrualOnly || isAccrualAccounting()) &&
(!row.accrualOnly || isAccrualAccounting() || basicControls.has(row.controlName)) &&
(!row.deferredOnly || isDeferredOnly()) && (!row.deferredOnly || isDeferredOnly()) &&
(!row.deferredCapitalizationOnly || isDeferredCapitalizationOnly()) && (!row.deferredCapitalizationOnly || isDeferredCapitalizationOnly()) &&
(!row.deferredBuyDownOnly || isDeferredBuyDownOnly()) && (!row.deferredBuyDownOnly || isDeferredBuyDownOnly()) &&
@@ -72,6 +78,334 @@
} }
} }
</div> </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>
} }
</div> </div>
@@ -104,23 +104,6 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
@Input() accountingRuleData: any; @Input() accountingRuleData: any;
@Input() loanProductFormValid: boolean; @Input() loanProductFormValid: boolean;
@Input() deferredIncomeRecognition: DeferredIncomeRecognition; @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; public loanProductAccountingForm!: UntypedFormGroup;
@@ -168,73 +151,6 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
if (this.loanProductAccountingForm) { if (this.loanProductAccountingForm) {
this.setDeferredIncomeRecognitionControls(); 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() { ngOnInit() {
@@ -261,7 +177,6 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
this.loanProductAccountingForm.get('accountingRule').disable(); this.loanProductAccountingForm.get('accountingRule').disable();
this.buildAccountingSectionConfigs(); this.buildAccountingSectionConfigs();
this.applyDefaultMapping();
const accountingMappings = this.loanProductsTemplate.accountingMappings; const accountingMappings = this.loanProductsTemplate.accountingMappings;
this.setDeferredIncomeRecognitionControls(); this.setDeferredIncomeRecognitionControls();
@@ -362,33 +277,6 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
'incomeFromGoodwillCreditFeesAccountId', 'incomeFromGoodwillCreditFeesAccountId',
'incomeFromGoodwillCreditPenaltyAccountId', 'incomeFromGoodwillCreditPenaltyAccountId',
'receivableInterestAccountId', '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', 'receivableFeeAccountId',
'receivablePenaltyAccountId', 'receivablePenaltyAccountId',
'intReceivablesGlAccountId', 'intReceivablesGlAccountId',
@@ -401,10 +289,7 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
]; ];
commonFields.forEach((field) => { commonFields.forEach((field) => {
if (!this.loanProductAccountingForm.contains(field)) { if (!this.loanProductAccountingForm.contains(field)) {
this.loanProductAccountingForm.addControl( this.loanProductAccountingForm.addControl(field, new UntypedFormControl('', Validators.required));
field,
new UntypedFormControl('', optionalCbsFields.includes(field) ? null : Validators.required)
);
} }
}); });
@@ -682,20 +567,8 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
} = this.loanProductAccountingForm.getRawValue(); } = this.loanProductAccountingForm.getRawValue();
const otherGlMappings = this.getOtherGlMappings(); 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 { return {
accountingRule: loanProductAccounting.accountingRule, ...loanProductAccounting,
...payload,
...(otherGlMappings ? { otherGlMappings } : {}) ...(otherGlMappings ? { otherGlMappings } : {})
}; };
} }
@@ -811,7 +684,7 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
inputLabel: 'Interest Receivable', inputLabel: 'Interest Receivable',
controlName: 'receivableInterestAccountId', controlName: 'receivableInterestAccountId',
options: this.assetAccountData, options: this.assetAccountData,
required: false, required: true,
accrualOnly: true accrualOnly: true
}, },
{ {
@@ -77,8 +77,6 @@
<input matInput type="number" formControlName="defaultTenure" [min]="tenureMin" [max]="tenureMax" /> <input matInput type="number" formControlName="defaultTenure" [min]="tenureMin" [max]="tenureMax" />
@if (isFdTenureLocked) { @if (isFdTenureLocked) {
<mat-hint>Between 12 and 15</mat-hint> <mat-hint>Between 12 and 15</mat-hint>
}
@if (isFdTenureLocked) {
<mat-error>Must be between <strong>12 and 15</strong> months</mat-error> <mat-error>Must be between <strong>12 and 15</strong> months</mat-error>
} }
</mat-form-field> </mat-form-field>
@@ -42,6 +42,10 @@
<mat-label>Max Books Outstanding</mat-label> <mat-label>Max Books Outstanding</mat-label>
<input matInput type="number" formControlName="maxBooksOutstanding" min="0" /> <input matInput type="number" formControlName="maxBooksOutstanding" min="0" />
</mat-form-field> </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-form-field appearance="outline" class="adv-field">
<mat-label>First Cheque Book Free</mat-label> <mat-label>First Cheque Book Free</mat-label>
<mat-select formControlName="firstBookFree"> <mat-select formControlName="firstBookFree">
@@ -62,6 +66,10 @@
} }
</mat-select> </mat-select>
</mat-form-field> </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>
</div> </div>
@@ -62,9 +62,11 @@ export class LoanProductChequeBookStepComponent {
], ],
defaultBookSize: ['25'], defaultBookSize: ['25'],
maxBooksOutstanding: [1], maxBooksOutstanding: [1],
reissueWhenLeaves: [5],
firstBookFree: ['YES'], firstBookFree: ['YES'],
freeBooksPerYear: [1], freeBooksPerYear: [1],
namePrinted: ['Account holder name'], namePrinted: ['Account holder name'],
chequePrintDescription: [''],
ppsThreshold: [ ppsThreshold: [
50000, 50000,
[ [
@@ -105,29 +105,6 @@ export class LoanProductDetailsStepComponent implements OnInit {
startDate: this.loanProductsTemplate.startDate ? new Date(this.loanProductsTemplate.startDate) : new Date(), startDate: this.loanProductsTemplate.startDate ? new Date(this.loanProductsTemplate.startDate) : new Date(),
closeDate: this.loanProductsTemplate.closeDate && new Date(this.loanProductsTemplate.closeDate) 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. */ /** True when editing a saved product; see ngOnInit. */
@@ -252,7 +229,7 @@ export class LoanProductDetailsStepComponent implements OnInit {
} }
get loanProductDetails() { get loanProductDetails() {
const loanProductDetailsFormData = this.loanProductDetailsForm.getRawValue(); const loanProductDetailsFormData = this.loanProductDetailsForm.value;
const prevStartDate: Date = this.loanProductDetailsForm.value.startDate; const prevStartDate: Date = this.loanProductDetailsForm.value.startDate;
const prevCloseDate: Date = this.loanProductDetailsForm.value.closeDate; const prevCloseDate: Date = this.loanProductDetailsForm.value.closeDate;
const dateFormat = this.settingsService.dateFormat; const dateFormat = this.settingsService.dateFormat;
@@ -1,60 +0,0 @@
<!--
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>
@@ -1,163 +0,0 @@
/**
* 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; 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 = [ readonly repaymentTypeOptions = [
{ id: 'emi', label: 'EMI', hint: 'Reducing balance, equal instalments' }, { id: 'emi', label: 'EMI', hint: 'Reducing balance, equal instalments' },
{ id: 'equal_principal', label: 'Equal principal', hint: 'Falling instalments' }, { id: 'equal_principal', label: 'Equal principal', hint: 'Falling instalments' },
{ id: 'flat', label: 'Flat rate', hint: 'Interest on original amount' } { 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' }
]; ];
/** `fineractId` is Fineract's repaymentFrequencyType (0 days, 1 weeks, 2 months). */ /** `fineractId` is Fineract's repaymentFrequencyType (0 days, 1 weeks, 2 months). */
@@ -6,45 +6,16 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import { Component, Input, OnInit, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms'; import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox'; import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; 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). * 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 * Unlike an Advance, the security only decides eligibility at sanction — it does not set a running
* limit. Defaults adapt to the chosen sub type (e.g. Personal/Education default to unsecured, * limit. UI-only for now: not sent in the create payload.
* Housing/Vehicle/Gold default to their usual collateral) but every field stays editable.
*/ */
@Component({ @Component({
selector: 'mifosx-loan-product-loan-security-step', selector: 'mifosx-loan-product-loan-security-step',
@@ -55,11 +26,9 @@ const SECURITY_PROFILES: Record<string, SecurityProfile> = {
MatCheckbox MatCheckbox
] ]
}) })
export class LoanProductLoanSecurityStepComponent implements OnInit { export class LoanProductLoanSecurityStepComponent {
private formBuilder = inject(UntypedFormBuilder); private formBuilder = inject(UntypedFormBuilder);
@Input() productTypeSubGroup: UntypedFormControl;
loanProductLoanSecurityForm: UntypedFormGroup; loanProductLoanSecurityForm: UntypedFormGroup;
readonly securityMandatoryOptions = [ readonly securityMandatoryOptions = [
@@ -143,29 +112,6 @@ export class LoanProductLoanSecurityStepComponent implements OnInit {
}); });
} }
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. */ /** Maximum LTV is derived from the margin (100 − margin) and shown read-only. */
get ltv(): number { get ltv(): number {
const margin = Number(this.loanProductLoanSecurityForm.get('margin').value); const margin = Number(this.loanProductLoanSecurityForm.get('margin').value);
@@ -7,14 +7,13 @@
--> -->
<form [formGroup]="loanProductPenalNpaForm"> <form [formGroup]="loanProductPenalNpaForm">
<!-- 1. PENAL --> <div class="adv-section">
<div class="adv-section pn-card pn-card--penal">
<div class="adv-section__header"> <div class="adv-section__header">
<i class="fas fa-percent"></i> <i class="fas fa-percent"></i>
<h3>Penal Charges</h3> <h3>Penal Charges</h3>
</div> </div>
<div class="adv-grid adv-grid--3"> <div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic"> <mat-form-field appearance="outline" class="adv-field">
<mat-label>Penal Charges Applicable</mat-label> <mat-label>Penal Charges Applicable</mat-label>
<mat-select formControlName="penalApplicable"> <mat-select formControlName="penalApplicable">
@for (opt of yesNoOptions; track opt) { @for (opt of yesNoOptions; track opt) {
@@ -22,11 +21,11 @@
} }
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic"> <mat-form-field appearance="outline" class="adv-field">
<mat-label>Penal Rate (% p.a.)</mat-label> <mat-label>Penal Rate (% p.a.)</mat-label>
<input matInput type="number" formControlName="penalRate" min="0" max="100" step="0.01" /> <input matInput type="number" formControlName="penalRate" min="0" max="100" step="0.01" />
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic"> <mat-form-field appearance="outline" class="adv-field">
<mat-label>Applied On</mat-label> <mat-label>Applied On</mat-label>
<mat-select formControlName="penalAppliedOn"> <mat-select formControlName="penalAppliedOn">
@for (opt of appliedOnOptions; track opt.id) { @for (opt of appliedOnOptions; track opt.id) {
@@ -34,7 +33,7 @@
} }
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic"> <mat-form-field appearance="outline" class="adv-field">
<mat-label>Penal Grace Days</mat-label> <mat-label>Penal Grace Days</mat-label>
<input matInput type="number" formControlName="penalGraceDays" min="0" /> <input matInput type="number" formControlName="penalGraceDays" min="0" />
</mat-form-field> </mat-form-field>
@@ -46,14 +45,13 @@
</div> </div>
</div> </div>
<!-- 2. SMA --> <div class="adv-section">
<div class="adv-section pn-card pn-card--sma">
<div class="adv-section__header"> <div class="adv-section__header">
<i class="fas fa-bell"></i> <i class="fas fa-triangle-exclamation"></i>
<h3>SMA (Special Mention Account)</h3> <h3>Asset Classification (SMA / NPA)</h3>
</div> </div>
<div class="adv-grid adv-grid--3"> <div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic"> <mat-form-field appearance="outline" class="adv-field">
<mat-label>Delinquency Bucket (SMA)</mat-label> <mat-label>Delinquency Bucket (SMA)</mat-label>
<mat-select formControlName="delinquencyBucketId"> <mat-select formControlName="delinquencyBucketId">
<mat-option [value]="''">— None —</mat-option> <mat-option [value]="''">— None —</mat-option>
@@ -63,38 +61,11 @@
</mat-select> </mat-select>
<mat-hint>SMA 0–30 / 31–60 / 61–90</mat-hint> <mat-hint>SMA 0–30 / 31–60 / 61–90</mat-hint>
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic"> <mat-form-field appearance="outline" class="adv-field">
<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> <mat-label>NPA After (days overdue)</mat-label>
<input matInput type="number" formControlName="npaDays" min="0" required /> <input matInput type="number" formControlName="npaDays" min="0" required />
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic"> <mat-form-field appearance="outline" class="adv-field">
<mat-label>NPA Trigger</mat-label> <mat-label>NPA Trigger</mat-label>
<mat-select formControlName="npaTrigger"> <mat-select formControlName="npaTrigger">
@for (opt of npaTriggerOptions; track opt.id) { @for (opt of npaTriggerOptions; track opt.id) {
@@ -102,7 +73,7 @@
} }
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic"> <mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest after NPA</mat-label> <mat-label>Interest after NPA</mat-label>
<mat-select formControlName="interestAfterNpa"> <mat-select formControlName="interestAfterNpa">
@for (opt of interestAfterNpaOptions; track opt.id) { @for (opt of interestAfterNpaOptions; track opt.id) {
@@ -110,31 +81,16 @@
} }
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
@if (isRevolvingSubType) { <mat-form-field appearance="outline" class="adv-field">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic"> <mat-label>Grace on Arrears Ageing (days)</mat-label>
<mat-label>NPA Out-of-Order Basis (CC/OD)</mat-label> <input matInput type="number" formControlName="graceOnArrearsAgeing" min="0" />
<mat-select formControlName="npaOutOfOrderBasis"> <mat-hint>Days before an amount counts as overdue</mat-hint>
@for (opt of npaOutOfOrderBasisOptions; track opt.id) { </mat-form-field>
<mat-option [value]="opt.id">{{ opt.label }}</mat-option> <mat-form-field appearance="outline" class="adv-field">
} <mat-label>In-Arrears Tolerance (₹)</mat-label>
</mat-select> <input matInput type="number" formControlName="inArrearsTolerance" min="0" />
</mat-form-field> <mat-hint>Small balance ignored for arrears</mat-hint>
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic"> </mat-form-field>
<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>
<div class="adv-subsection"> <div class="adv-subsection">
<div class="adv-checks"> <div class="adv-checks">
@@ -147,6 +103,4 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 4. ECL (SICR & LGD): hidden for now. The form controls stay in the .ts so the card can be restored. -->
</form> </form>
@@ -6,16 +6,14 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import { Component, Input, OnInit, inject } from '@angular/core'; import { Component, Input, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms'; import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox'; import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** /**
* Penal charges & asset-classification (SMA / NPA / SICR / LGD) step for a loan product (Figma step 6). * Penal charges & asset-classification (SMA / NPA) step for an Advances A/C product (Figma step 6).
* Shared by both Advances A/C and Loans A/C flows — fields are sent in the create/update payload via * UI-only for now — not sent in the create payload.
* `loanProductPenalNpa`, mapped onto the core product fields and the LoanProductAdvanceExt satellite params
* in create-loan-product.component.ts.
*/ */
@Component({ @Component({
selector: 'mifosx-loan-product-penal-npa-step', selector: 'mifosx-loan-product-penal-npa-step',
@@ -26,30 +24,13 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatCheckbox MatCheckbox
] ]
}) })
export class LoanProductPenalNpaStepComponent implements OnInit { export class LoanProductPenalNpaStepComponent {
private formBuilder = inject(UntypedFormBuilder); private formBuilder = inject(UntypedFormBuilder);
@Input() loanProductsTemplate: any; @Input() loanProductsTemplate: any;
@Input() productTypeGroup: UntypedFormControl;
@Input() productTypeSubGroup: UntypedFormControl;
loanProductPenalNpaForm: UntypedFormGroup; 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 = [ readonly yesNoOptions = [
'YES', 'YES',
'NO' 'NO'
@@ -62,8 +43,7 @@ export class LoanProductPenalNpaStepComponent implements OnInit {
readonly npaTriggerOptions = [ readonly npaTriggerOptions = [
{ id: 'maturity', label: 'Dues overdue after maturity' }, { 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 = [ readonly interestAfterNpaOptions = [
@@ -71,47 +51,10 @@ export class LoanProductPenalNpaStepComponent implements OnInit {
{ id: 'suspense', label: 'Move to Interest Suspense GL' } { 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[] { get delinquencyBucketOptions(): any[] {
return this.loanProductsTemplate?.delinquencyBucketOptions ?? []; 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() { constructor() {
this.loanProductPenalNpaForm = this.formBuilder.group({ this.loanProductPenalNpaForm = this.formBuilder.group({
penalApplicable: ['YES'], penalApplicable: ['YES'],
@@ -135,124 +78,13 @@ export class LoanProductPenalNpaStepComponent implements OnInit {
npaTrigger: ['maturity'], npaTrigger: ['maturity'],
interestAfterNpa: ['undebited'], interestAfterNpa: ['undebited'],
graceOnArrearsAgeing: [0], graceOnArrearsAgeing: [0],
smaStartAfterMonths: [
1,
[
Validators.required,
Validators.min(0)
]
],
stockStatementValidityDays: [
90,
[Validators.min(0)]
],
inArrearsTolerance: [0], inArrearsTolerance: [0],
upgradeOnlyAllArrears: [true], 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() { get loanProductPenalNpa() {
const value = this.loanProductPenalNpaForm.getRawValue(); return 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,36 +6,12 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/. file, You can obtain one at http://mozilla.org/MPL/2.0/.
--> -->
<div class="rv"> <mifosx-loan-product-summary
<p class="rv__intro"> [action]="'preview'"
Check the details below. Use <strong>Submit</strong> at the bottom to send the product for approval. [loanProduct]="loanProduct"
</p> [loanProductsTemplate]="loanProductsTemplate"
[paymentAllocations]="loanProduct.paymentAllocation"
@for (section of sections; track section.title) { [creditAllocations]="loanProduct.creditAllocation"
<div class="rv__card"> [supportedInterestRefundTypes]="loanProduct.supportedInterestRefundTypes"
<div class="rv__head"><i class="fas {{ section.icon }}"></i>{{ section.title }}</div> >
<div class="rv__grid"> </mifosx-loan-product-summary>
@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,74 +47,3 @@ mat-divider {
.repayment-strategy { .repayment-strategy {
color: $status-approved; 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,246 +6,44 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import { Component, Input, Output, EventEmitter } from '@angular/core'; import { Component, OnInit, Input, Output, EventEmitter, OnChanges, SimpleChanges } from '@angular/core';
import { productCategoryLabel, productTypeLabel } from '../../loan-product-types'; 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';
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({ @Component({
selector: 'mifosx-loan-product-preview-step', selector: 'mifosx-loan-product-preview-step',
templateUrl: './loan-product-preview-step.component.html', templateUrl: './loan-product-preview-step.component.html',
styleUrls: ['./loan-product-preview-step.component.scss'], styleUrls: ['./loan-product-preview-step.component.scss'],
imports: [] imports: [
...STANDALONE_SHARED_IMPORTS,
LoanProductSummaryComponent,
FaIconComponent
]
}) })
export class LoanProductPreviewStepComponent { export class LoanProductPreviewStepComponent implements OnInit, OnChanges {
@Input() loanProductsTemplate: any; @Input() loanProductsTemplate: any;
@Input() accountingRuleData: any; @Input() accountingRuleData: any;
@Input() loanProduct: any; @Input() loanProduct: any;
@Input() isValid = true; @Input() isValid: boolean = true;
@Output() submitEvent = new EventEmitter(); @Output() submitEvent = new EventEmitter();
/** Keys shown in a section above; everything else non-empty goes to "Other settings". */ isAdvancedPaymentAllocation = false;
private readonly shown = new Set<string>();
private cachedFor: any = null;
private cachedSections: ReviewSection[] = [];
private cachedOther: ReviewRow[] = [];
/** Keys that are technical or already covered, never shown. */ constructor() {}
private readonly hidden = new Set<string>([
'locale',
'dateFormat',
'charges',
'accountingRule',
'inMultiplesOf',
'repaymentEvery',
'repaymentFrequencyType',
'interestRateFrequencyType',
'amortizationType',
'interestType',
'interestCalculationPeriodType',
'transactionProcessingStrategyCode',
'daysInYearType',
'daysInMonthType',
'isInterestRecalculationEnabled',
'glAccount',
'interestRatePerPeriod'
]);
get sections(): ReviewSection[] { ngOnInit() {
this.build(); this.advancedPaymentAllocation();
return this.cachedSections;
} }
get otherSettings(): ReviewRow[] { ngOnChanges(changes: SimpleChanges): void {
this.build(); this.advancedPaymentAllocation();
return this.cachedOther;
} }
/** Rebuilt only when the product object changes (it is stable until a form value changes). */ advancedPaymentAllocation() {
private build(): void { this.isAdvancedPaymentAllocation = LoanProducts.isAdvancedPaymentAllocationStrategy(
const p = this.loanProduct; this.loanProduct.transactionProcessingStrategyCode
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,18 +85,9 @@ export class LoanProductSecurityLienStepComponent implements OnInit {
'Fixed Deposit – Regular', 'Fixed Deposit – Regular',
'Fixed Deposit – Senior Citizen', 'Fixed Deposit – Senior Citizen',
'Recurring Deposit – Regular', '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 = [ readonly yesNoOptions = [
'YES', 'YES',
'NO' 'NO'
@@ -120,13 +111,6 @@ export class LoanProductSecurityLienStepComponent implements OnInit {
if (margin != null && !this.loanProductSecurityLienForm.get('margin').dirty) { if (margin != null && !this.loanProductSecurityLienForm.get('margin').dirty) {
this.loanProductSecurityLienForm.get('margin').setValue(margin, { emitEvent: false }); 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. */ /** This whole step is only relevant to an Advances A/C product. */
@@ -45,11 +45,6 @@ export interface RegisterRateSelection {
const PARTICULAR_BY_SUB_TYPE: Record<string, string> = { const PARTICULAR_BY_SUB_TYPE: Record<string, string> = {
fd: 'against fdr', fd: 'against fdr',
rd: 'against rd', rd: 'against rd',
dds: 'against dds',
lic: 'against lic',
warehouse: 'warehouse',
bills: 'bills purchased',
education: 'education loan',
gold: 'gold loan', gold: 'gold loan',
cc: 'cash credit', cc: 'cash credit',
overdraft: 'overdraft', overdraft: 'overdraft',
@@ -81,33 +81,9 @@
placeholder="Search by name or short name…" placeholder="Search by name or short name…"
/> />
</div> </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> </div>
@if (totalCount === 0 && pendingRows.length === 0) { @if (totalCount === 0) {
<div class="pl-empty"> <div class="pl-empty">
<div class="pl-empty-ic"><fa-icon icon="hand-holding-usd"></fa-icon></div> <div class="pl-empty-ic"><fa-icon icon="hand-holding-usd"></fa-icon></div>
<h3>No loan products yet</h3> <h3>No loan products yet</h3>
@@ -147,16 +123,6 @@
</td> </td>
</ng-container> </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"> <ng-container matColumnDef="closeDate">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Expiry Date' | translate }}</th> <th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Expiry Date' | translate }}</th>
<td mat-cell *matCellDef="let p"> <td mat-cell *matCellDef="let p">
@@ -171,100 +137,48 @@
<ng-container matColumnDef="status"> <ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Status' | translate }}</th> <th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let p"> <td mat-cell *matCellDef="let p">
@if (p.pending) { <span
<span class="pl-status-pill"
class="pl-status-pill pl-status-pill--pending" [class.pl-status-pill--active]="p.status === 'loanProduct.active'"
matTooltip="Requested by {{ p.madeBy }}; visible as a product once a checker approves" [class.pl-status-pill--inactive]="p.status !== 'loanProduct.active'"
> >
<fa-icon icon="hourglass-half"></fa-icon> Pending approval <fa-icon [icon]="p.status === 'loanProduct.active' ? 'check-circle' : 'times-circle'"></fa-icon>
</span> {{
} @else { p.status === 'loanProduct.active'
<span ? ('labels.inputs.Active' | translate)
class="pl-status-pill" : ('labels.catalogs.Inactive' | translate)
[class.pl-status-pill--active]="p.status === 'loanProduct.active'" }}
[class.pl-status-pill--inactive]="p.status !== 'loanProduct.active'" </span>
>
<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> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="num">Actions</th> <th mat-header-cell *matHeaderCellDef class="num">Actions</th>
<td mat-cell *matCellDef="let p" class="num"> <td mat-cell *matCellDef="let p" class="num">
@if (p.pending) { <button
<button type="button"
type="button" class="pl-action-btn pl-action-btn--view"
class="pl-action-btn pl-action-btn--view" [routerLink]="[p.id]"
(click)="$event.stopPropagation(); viewPending(p)" (click)="$event.stopPropagation()"
matTooltip="View the submitted details" matTooltip="View product details"
> >
<fa-icon icon="eye"></fa-icon><span>View</span> <fa-icon icon="eye"></fa-icon><span>View</span>
</button> </button>
<button <button
type="button" type="button"
class="pl-action-btn pl-action-btn--edit" class="pl-action-btn pl-action-btn--edit"
disabled [routerLink]="[p.id, 'edit']"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
matTooltip="Locked until the request is approved or rejected" matTooltip="Edit this product"
> *mifosxHasPermission="'UPDATE_LOANPRODUCT'"
<fa-icon icon="edit"></fa-icon><span>Edit</span> >
</button> <fa-icon icon="edit"></fa-icon><span>Edit</span>
} @else { </button>
<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> </td>
</ng-container> </ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr <tr mat-row *matRowDef="let row; columns: displayedColumns" [routerLink]="[row.id]" class="pl-row"></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> </table>
@if (filteredCount === 0 && totalCount > 0) { @if (filteredCount === 0 && totalCount > 0) {
@@ -61,18 +61,3 @@
background: #ede9fe; background: #ede9fe;
color: #5b21b6; 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,7 +8,6 @@
/** Angular Imports */ /** Angular Imports */
import { Component, OnInit, TemplateRef, ElementRef, ViewChild, AfterViewInit, inject } from '@angular/core'; import { Component, OnInit, TemplateRef, ElementRef, ViewChild, AfterViewInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatPaginator } from '@angular/material/paginator'; import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort'; import { MatSort, MatSortHeader } from '@angular/material/sort';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
@@ -28,8 +27,8 @@ import {
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
/** rxjs Imports */ /** rxjs Imports */
import { switchMap, catchError, finalize, map, tap } from 'rxjs/operators'; import { switchMap, catchError, finalize } from 'rxjs/operators';
import { EMPTY, forkJoin, of } from 'rxjs'; import { EMPTY } from 'rxjs';
/* Custom Services */ /* Custom Services */
import { PopoverService } from '../../configuration-wizard/popover/popover.service'; import { PopoverService } from '../../configuration-wizard/popover/popover.service';
@@ -43,9 +42,6 @@ import { MatTooltip } from '@angular/material/tooltip';
import { StatusLookupPipe } from '../../pipes/status-lookup.pipe'; import { StatusLookupPipe } from '../../pipes/status-lookup.pipe';
import { DateFormatPipe } from '../../pipes/date-format.pipe'; import { DateFormatPipe } from '../../pipes/date-format.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; 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'; import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS, productCategoryLabel, productTypeLabel } from './loan-product-types';
@Component({ @Component({
@@ -54,7 +50,6 @@ import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS, productCategoryLabel, productTy
styleUrls: ['./loan-products.component.scss'], styleUrls: ['./loan-products.component.scss'],
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent, FaIconComponent,
MatTable, MatTable,
MatSort, MatSort,
@@ -85,10 +80,6 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
private errorHandler = inject(ErrorHandlerService); private errorHandler = inject(ErrorHandlerService);
loanProductsData: any[] = []; 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[] = [ displayedColumns: string[] = [
'name', 'name',
'shortName', 'shortName',
@@ -139,8 +130,8 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
return this.totalCount - this.activeCount; return this.totalCount - this.activeCount;
} }
@ViewChild(MatPaginator) paginator: MatPaginator; @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort; @ViewChild(MatSort, { static: true }) sort: MatSort;
/* Reference of create loan product button */ /* Reference of create loan product button */
@ViewChild('buttonCreateLoanProduct') buttonCreateLoanProduct: ElementRef<any>; @ViewChild('buttonCreateLoanProduct') buttonCreateLoanProduct: ElementRef<any>;
@@ -169,75 +160,7 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
this.dataSource.sort = this.sort; this.dataSource.sort = this.sort;
this.dataSource.filterPredicate = (product: any) => this.matchesFilters(product); this.dataSource.filterPredicate = (product: any) => this.matchesFilters(product);
this.filteredCount = this.totalCount; this.filteredCount = this.totalCount;
this.dataSource.connect().subscribe(() => (this.filteredCount = this.dataSource.filteredData.length)); this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.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) { applyFilter(filterValue: string) {
@@ -287,8 +210,6 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
* To show popover. * To show popover.
*/ */
ngAfterViewInit() { ngAfterViewInit() {
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
if (this.configurationWizardService.showLoanProductsPage) { if (this.configurationWizardService.showLoanProductsPage) {
setTimeout(() => { setTimeout(() => {
this.showPopover( this.showPopover(
@@ -346,34 +267,6 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
/** /**
* Opens the import loan product dialog. * 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 { openImportDialog(): void {
const importDialogRef = this.dialog.open(ImportLoanProductDialogComponent, { const importDialogRef = this.dialog.open(ImportLoanProductDialogComponent, {
width: '50rem' width: '50rem'
@@ -425,10 +318,7 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
.subscribe({ .subscribe({
next: (data: any) => { next: (data: any) => {
this.loanProductsData = data; this.loanProductsData = data;
this.dataSource.data = [ this.dataSource.data = this.loanProductsData;
...this.pendingRows,
...this.loanProductsData
];
this.errorHandler.showSuccess('Loan product imported successfully!'); this.errorHandler.showSuccess('Loan product imported successfully!');
}, },
error: () => { error: () => {
@@ -1,39 +0,0 @@
<!--
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>
@@ -1,74 +0,0 @@
/**
* 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;
}
@@ -1,78 +0,0 @@
/**
* 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,17 +48,6 @@
<mat-label>{{ 'labels.inputs.Days Till' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Days Till' | translate }}</mat-label>
<input matInput type="number" required formControlName="maximumAgeDays" min="1" /> <input matInput type="number" required formControlName="maximumAgeDays" min="1" />
</mat-form-field> </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>
</div> </div>
@@ -61,18 +61,15 @@ export class CreateRangeComponent implements OnInit {
Validators.pattern('^(0*[1-9][0-9]*)$'), Validators.pattern('^(0*[1-9][0-9]*)$'),
Validators.max(10000) Validators.max(10000)
] ]
], ]
// RBI 2026 ECL framework: which asset-classification stage this range maps to (optional).
stage: ['']
}); });
} }
submit(): void { submit(): void {
const delinquencyRangeFormData = this.delinquencyRangeForm.value; const delinquencyRangeFormData = this.delinquencyRangeForm.value;
const locale = this.settingsService.language.code; const locale = this.settingsService.language.code;
const data: any = { const data = {
...delinquencyRangeFormData, ...delinquencyRangeFormData,
stage: delinquencyRangeFormData.stage === '' ? undefined : Number(delinquencyRangeFormData.stage),
locale locale
}; };
this.productsService.createDelinquencyRange(data).subscribe((response: any) => { this.productsService.createDelinquencyRange(data).subscribe((response: any) => {
@@ -47,17 +47,6 @@
<mat-label>{{ 'labels.inputs.Days Till' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Days Till' | translate }}</mat-label>
<input matInput type="number" formControlName="maximumAgeDays" /> <input matInput type="number" formControlName="maximumAgeDays" />
</mat-form-field> </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>
</div> </div>
@@ -68,18 +68,15 @@ export class EditRangeComponent implements OnInit {
Validators.pattern('^(0*[1-9][0-9]*?)$'), Validators.pattern('^(0*[1-9][0-9]*?)$'),
Validators.max(10000) Validators.max(10000)
] ]
], ]
// RBI 2026 ECL framework: which asset-classification stage this range maps to (optional).
stage: [this.delinquencyRangeData.stage ?? '']
}); });
} }
submit(): void { submit(): void {
const delinquencyRangeFormData = this.delinquencyRangeForm.value; const delinquencyRangeFormData = this.delinquencyRangeForm.value;
const locale = this.settingsService.language.code; const locale = this.settingsService.language.code;
const data: any = { const data = {
...delinquencyRangeFormData, ...delinquencyRangeFormData,
stage: delinquencyRangeFormData.stage === '' ? undefined : Number(delinquencyRangeFormData.stage),
locale locale
}; };
this.productsService.updateDelinquencyRange(this.delinquencyRangeData.id, data).subscribe((response: any) => { this.productsService.updateDelinquencyRange(this.delinquencyRangeData.id, data).subscribe((response: any) => {
@@ -52,25 +52,6 @@
<span class="vr-label">{{ 'labels.inputs.Days Till' | translate }}</span> <span class="vr-label">{{ 'labels.inputs.Days Till' | translate }}</span>
<span class="vr-value">{{ delinquencyRangeData.maximumAgeDays }}</span> <span class="vr-value">{{ delinquencyRangeData.maximumAgeDays }}</span>
</div> </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> </div>
</div> </div>
@@ -34,16 +34,5 @@
</div> </div>
<i class="fas fa-chevron-right mdb-card__arrow"></i> <i class="fas fa-chevron-right mdb-card__arrow"></i>
</div> </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>
</div> </div>
@@ -1,64 +0,0 @@
<!--
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>
@@ -1,32 +0,0 @@
/**
* 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();
}
}
@@ -1,19 +0,0 @@
/**
* 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;
}
@@ -1,118 +0,0 @@
/**
* 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,8 +120,6 @@ import { DelinquencyBucketComponent } from './manage-delinquency-buckets/delinqu
import { DelinquencyRangeComponent } from './manage-delinquency-buckets/delinquency-range/delinquency-range.component'; import { DelinquencyRangeComponent } from './manage-delinquency-buckets/delinquency-range/delinquency-range.component';
import { DelinquencyBucketComponentsResolver } from './manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.resolver'; 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 { 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 { 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 { 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'; import { CreateRangeComponent } from './manage-delinquency-buckets/delinquency-range/create-range/create-range.component';
@@ -226,15 +224,6 @@ const routes: Routes = [
}, },
{ {
path: 'edit', path: 'edit',
component: CreateLoanProductComponent,
data: { title: 'Edit Loan Product', breadcrumb: 'Edit', routeParamBreadcrumb: false },
resolve: {
loanProductAndTemplate: LoanProductAndTemplateResolver,
configurations: GlobalConfigurationsResolver
}
},
{
path: 'edit-legacy',
component: EditLoanProductComponent, component: EditLoanProductComponent,
data: { title: 'Edit Loan Product', breadcrumb: 'Edit', routeParamBreadcrumb: false }, data: { title: 'Edit Loan Product', breadcrumb: 'Edit', routeParamBreadcrumb: false },
resolve: { resolve: {
@@ -674,14 +663,6 @@ const routes: Routes = [
] ]
} }
] ]
},
{
path: 'floor-master',
component: PrudentialFloorComponent,
data: { title: 'Prudential Floor Master', breadcrumb: 'Prudential Floor Master' },
resolve: {
prudentialFloors: PrudentialFloorComponentsResolver
}
} }
] ]
}, },
@@ -1123,7 +1104,6 @@ const routes: Routes = [
CollateralTemplateResolver, CollateralTemplateResolver,
DelinquencyRangeComponentsResolver, DelinquencyRangeComponentsResolver,
DelinquencyBucketComponentsResolver, DelinquencyBucketComponentsResolver,
PrudentialFloorComponentsResolver,
CurrentProductsResolver, CurrentProductsResolver,
CurrentProductsTemplateResolver, CurrentProductsTemplateResolver,
CurrentProductResolver, CurrentProductResolver,
+2 -41
View File
@@ -12,8 +12,7 @@ import { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
import { SILENT_HTTP_ERROR } from 'app/core/http/silent-error.token'; import { SILENT_HTTP_ERROR } from 'app/core/http/silent-error.token';
/** rxjs Imports */ /** rxjs Imports */
import { Observable, forkJoin, of } from 'rxjs'; import { Observable } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
/** Custom Services */ /** Custom Services */
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
@@ -584,17 +583,6 @@ export class ProductsService {
return this.http.put(`/taxes/group/${taxGroupId}`, taxGroup); 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 * @returns {Observable<any>} Delinquency Range data
*/ */
@@ -879,36 +867,9 @@ export class ProductsService {
} }
/** Requests of one entity (e.g. DELINQUENCY_RANGE) still awaiting a checker. */ /** 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> { getPendingMakerCheckerByEntity(entityName: string): Observable<any> {
const params = new HttpParams().set('entityName', entityName); const params = new HttpParams().set('entityName', entityName);
const context = new HttpContext().set(SILENT_HTTP_ERROR, true); return this.http.get('/makercheckers', { params, 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. */ /** Full maker-checker entry, including the submitted payload (commandAsJson), which the list call omits. */
@@ -63,53 +63,9 @@ export class ProductEditDiffDialogComponent implements OnInit {
'id', 'id',
'productId', 'productId',
'savingsProductId', '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 /** The two reserved otherGlMappings entries a checker actually needs to see by name -- see
* AccountingProcessorHelper.SAVINGS_INTEREST_PAYABLE_MAPPING / SAVINGS_INTEREST_PAID_MAPPING. Any * 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 * other entry in the assets/liabilities/expenses/income lists is a free-form "Other" row with no
@@ -128,7 +84,6 @@ export class ProductEditDiffDialogComponent implements OnInit {
digitsAfterDecimal: 'currency.decimalPlaces', digitsAfterDecimal: 'currency.decimalPlaces',
inMultiplesOf: 'currency.inMultiplesOf', inMultiplesOf: 'currency.inMultiplesOf',
taxGroupId: 'taxGroup.id', taxGroupId: 'taxGroup.id',
delinquencyBucketId: 'delinquencyBucket.id',
// Enum fields the stored product exposes as "<name>Type": {id, value}", not "<name>TypeId" -- // 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 // without these the "current" side always read as blank, so these showed as changed on every
// edit even when the maker never touched them. // edit even when the maker never touched them.
@@ -169,13 +124,7 @@ export class ProductEditDiffDialogComponent implements OnInit {
penaltiesReceivableAccountId: 'penaltyReceivableAccount', penaltiesReceivableAccountId: 'penaltyReceivableAccount',
interestPayableAccountId: 'interestPayableAccount', interestPayableAccountId: 'interestPayableAccount',
interestReceivableAccountId: 'interestReceivableAccount', 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>(); private glAccountNamesById = new Map<string, string>();
@@ -194,7 +143,7 @@ export class ProductEditDiffDialogComponent implements OnInit {
.map((key) => ({ key, originalValue: this.originalValueFor(key, original) })) .map((key) => ({ key, originalValue: this.originalValueFor(key, original) }))
.filter(({ key, originalValue }) => !sameValue(originalValue, proposed[key])) .filter(({ key, originalValue }) => !sameValue(originalValue, proposed[key]))
.map(({ key, originalValue }) => ({ .map(({ key, originalValue }) => ({
label: ProductEditDiffDialogComponent.FRIENDLY_LABELS[key] ?? humanizeKey(key), label: humanizeKey(key),
original: this.displayValue(originalValue, key, original), original: this.displayValue(originalValue, key, original),
proposed: this.displayValue(proposed[key], key, original) proposed: this.displayValue(proposed[key], key, original)
})) }))
-4
View File
@@ -127,10 +127,6 @@ export function normalizeValue(value: any): any {
if (typeof value === 'boolean') { if (typeof value === 'boolean') {
return value; 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); const asNumber = Number(value);
return Number.isNaN(asNumber) ? value : asNumber; return Number.isNaN(asNumber) ? value : asNumber;
} }
+1 -3
View File
@@ -7,8 +7,6 @@
*/ */
import { Observable } from 'rxjs'; 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 { ProductsService } from 'app/products/products.service';
import { ClientsService } from 'app/clients/clients.service'; import { ClientsService } from 'app/clients/clients.service';
@@ -61,7 +59,7 @@ const ENTITY_FETCHERS: Record<string, (id: string, s: EntityFetchServices) => Ob
SAVINGSPRODUCT: (id, s) => s.products.getSavingProduct(id, true), SAVINGSPRODUCT: (id, s) => s.products.getSavingProduct(id, true),
CURRENTPRODUCT: (id, s) => s.products.getCurrentProduct(id, true), CURRENTPRODUCT: (id, s) => s.products.getCurrentProduct(id, true),
SHAREPRODUCT: (id, s) => s.products.getShareProduct(id, true), SHAREPRODUCT: (id, s) => s.products.getShareProduct(id, true),
LOANPRODUCT: (id, s) => s.products.getLoanProduct(id, true).pipe(map((product: any) => withExtensions(product))), LOANPRODUCT: (id, s) => s.products.getLoanProduct(id, true),
FIXEDDEPOSITPRODUCT: (id, s) => s.products.getFixedDepositProductAndTemplate(id), FIXEDDEPOSITPRODUCT: (id, s) => s.products.getFixedDepositProductAndTemplate(id),
RECURRINGDEPOSITPRODUCT: (id, s) => s.products.getRecurringDepositProduct(id, true), RECURRINGDEPOSITPRODUCT: (id, s) => s.products.getRecurringDepositProduct(id, true),
CHARGE: (id, s) => s.products.getCharge(id, true), CHARGE: (id, s) => s.products.getCharge(id, true),
+42 -40
View File
@@ -45,15 +45,17 @@
"hasMask": false, "hasMask": false,
"ao": 0, "ao": 0,
"ks": { "a": { "a": 0, "k": [ "ks": { "a": { "a": 0, "k": [
-17.052704811096184, 0,
-15.0467209815979 0,
0
], "ix": 1 }, "s": { "a": 0, "k": [ ], "ix": 1 }, "s": { "a": 0, "k": [
362.228, 394,
362.228, 394,
100 100
], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [
188.2303284168625, 250,
195.49656352277756 250,
0
], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } },
"shapes": [ "shapes": [
{ {
@@ -341,16 +343,16 @@
"g": { "p": 3, "k": { "a": 0, "k": [ "g": { "p": 3, "k": { "a": 0, "k": [
0, 0,
1, 1,
0.4470588235294118, 0.6,
0, 0.2,
0.485, 0.485,
0.9725490196078431,
0.2235294117647059,
0.5019607843137255,
1, 1,
0.9411764705882353, 0.6,
0, 0.2,
1 1,
1,
0.6,
0.2
], "ix": 8 } }, ], "ix": 8 } },
"t": 1, "t": 1,
"a": { "a": 0, "k": 0 }, "a": { "a": 0, "k": 0 },
@@ -363,7 +365,7 @@
"lj": 1, "lj": 1,
"ml": 4, "ml": 4,
"o": { "a": 0, "k": 100, "ix": 9 }, "o": { "a": 0, "k": 100, "ix": 9 },
"w": { "a": 0, "k": 2, "ix": 10 } "w": { "a": 0, "k": 2.5, "ix": 10 }
}, },
{ {
"ty": "fl", "ty": "fl",
@@ -402,8 +404,8 @@
"o": { "o": {
"a": 1, "a": 1,
"k": [ "k": [
{ "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [0], "t": 0 }, { "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [28], "t": 0 },
{ "s": [360], "t": 90.0000036657751 } { "s": [388], "t": 90.0000036657751 }
], ],
"ix": 3 "ix": 3
}, },
@@ -437,7 +439,7 @@
250, 250,
250, 250,
0 0
], "ix": 2 }, "r": { "a": 0, "k": 60, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } },
"shapes": [ "shapes": [
{ {
"ty": "gr", "ty": "gr",
@@ -723,16 +725,16 @@
], "ix": 5 }, ], "ix": 5 },
"g": { "p": 3, "k": { "a": 0, "k": [ "g": { "p": 3, "k": { "a": 0, "k": [
0, 0,
0, 1,
0.7764705882352941, 1,
1, 1,
0.485, 0.485,
0.24705882352941178,
0.38823529411764707,
1, 1,
1, 1,
0.49411764705882355, 1,
0, 1,
1,
1,
1 1
], "ix": 8 } }, ], "ix": 8 } },
"t": 1, "t": 1,
@@ -746,7 +748,7 @@
"lj": 1, "lj": 1,
"ml": 4, "ml": 4,
"o": { "a": 0, "k": 100, "ix": 9 }, "o": { "a": 0, "k": 100, "ix": 9 },
"w": { "a": 0, "k": 0.5, "ix": 10 } "w": { "a": 0, "k": 2.5, "ix": 10 }
}, },
{ {
"ty": "fl", "ty": "fl",
@@ -813,14 +815,14 @@
0, 0,
0 0
], "ix": 1 }, "s": { "a": 0, "k": [ ], "ix": 1 }, "s": { "a": 0, "k": [
362.228, 330,
362.228, 330,
100 100
], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [ ], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [
250, 250,
250, 250,
0 0
], "ix": 2 }, "r": { "a": 0, "k": 30, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } }, ], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } },
"shapes": [ "shapes": [
{ {
"ty": "gr", "ty": "gr",
@@ -1106,17 +1108,17 @@
], "ix": 5 }, ], "ix": 5 },
"g": { "p": 3, "k": { "a": 0, "k": [ "g": { "p": 3, "k": { "a": 0, "k": [
0, 0,
1, 0.0784,
0.8235294117647058, 0.7059,
0, 0.2118,
0.485, 0.485,
0.5019607843137255, 0.0784,
0.8117647058823529, 0.7059,
0.5019607843137255, 0.2118,
1, 1,
0, 0.0784,
0.8, 0.7059,
1 0.2118
], "ix": 8 } }, ], "ix": 8 } },
"t": 1, "t": 1,
"a": { "a": 0, "k": 0 }, "a": { "a": 0, "k": 0 },
@@ -1129,7 +1131,7 @@
"lj": 1, "lj": 1,
"ml": 4, "ml": 4,
"o": { "a": 0, "k": 100, "ix": 9 }, "o": { "a": 0, "k": 100, "ix": 9 },
"w": { "a": 0, "k": 0.5, "ix": 10 } "w": { "a": 0, "k": 2.5, "ix": 10 }
}, },
{ {
"ty": "fl", "ty": "fl",
@@ -1168,8 +1170,8 @@
"o": { "o": {
"a": 1, "a": 1,
"k": [ "k": [
{ "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [0], "t": 0 }, { "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [-28], "t": 0 },
{ "s": [360], "t": 90.0000036657751 } { "s": [332], "t": 90.0000036657751 }
], ],
"ix": 3 "ix": 3
}, },
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */ /* tslint:disable */
export default { export default {
'mifos_x': { 'mifos_x': {
'version': '261013', 'version': '261012',
'hash': 'd88f2a6eb' 'hash': '6b0f218b'
}, },
'allow_switching_backend_instance': true 'allow_switching_backend_instance': true
}; };
+6 -6
View File
@@ -325,10 +325,10 @@
}; };
document.head.appendChild(script); document.head.appendChild(script);
// Progress simulation stages (Guaranteed ~2 seconds duration on refresh) // Progress simulation stages (Guaranteed ~4 seconds duration on refresh: 2% every 90ms reaches 90% in about 4s)
var interval = setInterval(function () { var interval = setInterval(function () {
if (progress < 90) { if (progress < 90) {
progress += 5; progress += 2;
} else if (angularReady && progress < 100) { } else if (angularReady && progress < 100) {
progress += 5; progress += 5;
} }
@@ -351,7 +351,7 @@
} }
}, 90); }, 90);
// Enforce minimum hold time (2000ms = 2 seconds) so refresh always shows the animation // Enforce minimum hold time (4000ms = 4 seconds) so refresh always shows the animation
setTimeout(function () { setTimeout(function () {
minTimePassed = true; minTimePassed = true;
if (angularReady) { if (angularReady) {
@@ -359,7 +359,7 @@
if (bar) bar.style.width = '100%'; if (bar) bar.style.width = '100%';
dismissSplash(); dismissSplash();
} }
}, 2000); }, 4000);
function dismissSplash() { function dismissSplash() {
if (dismissed) return; if (dismissed) return;
@@ -387,12 +387,12 @@
} }
}; };
// Safety fallback (Max 7s in case of backend network failure) // Safety fallback (Max 9s in case of backend network failure)
setTimeout(function () { setTimeout(function () {
angularReady = true; angularReady = true;
minTimePassed = true; minTimePassed = true;
dismissSplash(); dismissSplash();
}, 7000); }, 9000);
})(); })();
</script> </script>