feat(loan-products): build out Loans A/C (EMI) stepper and wire it to the rate register
Adds the curated Loans A/C steps (security/collateral, amount & tenure, repayment & EMI, interest, prepayment & closure) and a product-type picker (loan-product-types.ts) shared by the create wizard, product list and account picker. The interest step and a new register-rate-view read the live Loan Interest Rate Register for a product's chart/CIBIL rate instead of a hardcoded value, and a new loan-product-catalog service + template card back the loan account stepper's product pick. Also carries forward in-progress Loan Interest Rate Register UI work: an inline Add Slab panel (replacing window.prompt) and a Submit Details dialog for confirming name/effective dates before submitting for approval. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+43
@@ -0,0 +1,43 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
@if (product) {
|
||||
<div class="lptc">
|
||||
<div class="lptc__head">
|
||||
<strong>{{ product.name }}</strong>
|
||||
<span class="lptc__chip">{{ category }}</span>
|
||||
<span class="lptc__chip lptc__chip--type">{{ type }}</span>
|
||||
</div>
|
||||
<div class="lptc__grid">
|
||||
<div>
|
||||
<span class="lptc__label">Amount</span>
|
||||
<span>
|
||||
@if (product.minPrincipal != null || product.maxPrincipal != null) {
|
||||
{{ product.minPrincipal | number: '1.0-0' }} – {{ product.maxPrincipal | number: '1.0-0' }}
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="lptc__label">Tenure (repayments)</span>
|
||||
<span>
|
||||
@if (product.minNumberOfRepayments != null || product.maxNumberOfRepayments != null) {
|
||||
{{ product.minNumberOfRepayments }} – {{ product.maxNumberOfRepayments }}
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="lptc__label">Short name</span>
|
||||
<span>{{ product.shortName || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.lptc {
|
||||
margin: 4px 0 16px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 8px;
|
||||
background: #f0f9ff;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__chip {
|
||||
padding: 1px 10px;
|
||||
border-radius: 999px;
|
||||
background: #e0f2fe;
|
||||
color: #075985;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
&--type {
|
||||
background: #ede9fe;
|
||||
color: #5b21b6;
|
||||
}
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px 16px;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 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 } from '@angular/core';
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
import { productCategoryLabel, productTypeLabel } from 'app/products/loan-products/loan-product-types';
|
||||
|
||||
/**
|
||||
* The product picked on the loan account form, summarised with its category, type and the main limits, so the
|
||||
* officer can see what the account will be opened under before going on.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-loan-product-template-card',
|
||||
templateUrl: './loan-product-template-card.component.html',
|
||||
styleUrls: ['./loan-product-template-card.component.scss'],
|
||||
imports: [DecimalPipe]
|
||||
})
|
||||
export class LoanProductTemplateCardComponent {
|
||||
/** A product from the catalogue (the list shape), or undefined when none is picked. */
|
||||
@Input() product: any;
|
||||
|
||||
get category(): string {
|
||||
return productCategoryLabel(this.product?.advanceExtensions?.productTypeGroup);
|
||||
}
|
||||
|
||||
get type(): string {
|
||||
return productTypeLabel(
|
||||
this.product?.advanceExtensions?.productTypeGroup,
|
||||
this.product?.advanceExtensions?.productTypeSubGroup
|
||||
);
|
||||
}
|
||||
}
|
||||
+67
-2
@@ -32,6 +32,9 @@ import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { LoanProductCatalogService } from '../../services/loan-product-catalog.service';
|
||||
import { LoanProductTemplateCardComponent } from '../loan-product-template-card/loan-product-template-card.component';
|
||||
import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS } from 'app/products/loan-products/loan-product-types';
|
||||
|
||||
/**
|
||||
* Loans Account Details Step
|
||||
@@ -49,7 +52,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
MatStepperPrevious,
|
||||
FaIconComponent,
|
||||
MatStepperNext,
|
||||
AsyncPipe
|
||||
AsyncPipe,
|
||||
LoanProductTemplateCardComponent
|
||||
]
|
||||
})
|
||||
export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
|
||||
@@ -59,6 +63,7 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
|
||||
private translateService = inject(TranslateService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private commons = inject(Commons);
|
||||
private catalog = inject(LoanProductCatalogService);
|
||||
|
||||
//** Defining PlaceHolders for the search bar */
|
||||
placeHolderLabel = '';
|
||||
@@ -88,6 +93,27 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
|
||||
|
||||
loanId: any = null;
|
||||
|
||||
/** Category and product type narrow the product picker to the active products of that kind. */
|
||||
readonly categories = PRODUCT_TYPE_GROUPS;
|
||||
categoryFilter = '';
|
||||
typeFilter = '';
|
||||
/** Ids of the products active today; null until read, or if the read failed (then nothing is hidden). */
|
||||
private activeProductIds: Set<number> | null = null;
|
||||
/** Every product from the template, before any filter. */
|
||||
private allProducts: any[] = [];
|
||||
|
||||
/** Types offered for the chosen category; every type when no category is chosen. */
|
||||
get typeOptions() {
|
||||
return this.categoryFilter
|
||||
? (PRODUCT_SUB_TYPES[this.categoryFilter] ?? [])
|
||||
: Object.values(PRODUCT_SUB_TYPES).flat();
|
||||
}
|
||||
|
||||
/** The product picked in the form, with its category and type, for the template card. */
|
||||
get selectedProduct(): any {
|
||||
return this.catalog.get(this.loansAccountDetailsForm?.get('productId')?.value);
|
||||
}
|
||||
|
||||
loanProductSelected = false;
|
||||
/** Currency data. */
|
||||
protected productData: ReplaySubject<string[]> = new ReplaySubject<string[]>(1);
|
||||
@@ -115,7 +141,8 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
|
||||
this.maxDate = this.settingsService.maxFutureDate;
|
||||
this.buildDependencies();
|
||||
if (this.loansAccountTemplate) {
|
||||
this.productList = this.loansAccountTemplate.productOptions.sort(this.commons.dynamicSort('name'));
|
||||
this.allProducts = this.loansAccountTemplate.productOptions.sort(this.commons.dynamicSort('name'));
|
||||
this.productList = this.allProducts;
|
||||
if (this.loansAccountTemplate.loanProductId) {
|
||||
this.loansAccountDetailsForm.patchValue({
|
||||
productId: this.loansAccountTemplate.loanProductId,
|
||||
@@ -136,6 +163,44 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
|
||||
this.searchItem();
|
||||
});
|
||||
this.productData.next(this.productList.slice());
|
||||
this.loadActiveProducts();
|
||||
}
|
||||
|
||||
/**
|
||||
* A new account can only be opened on a product that is active today, and the picker can be narrowed by category
|
||||
* and type. Editing an existing account keeps every product, since its own product may have closed since.
|
||||
*/
|
||||
private loadActiveProducts(): void {
|
||||
if (this.loanId) {
|
||||
return;
|
||||
}
|
||||
this.catalog.activeProducts().subscribe((products) => {
|
||||
this.activeProductIds = products.length > 0 ? new Set(products.map((product) => Number(product.id))) : null;
|
||||
this.applyProductFilters();
|
||||
});
|
||||
}
|
||||
|
||||
onCategoryChange(): void {
|
||||
// A type belongs to one category, so a type from another category cannot stay selected.
|
||||
if (this.typeFilter && !this.typeOptions.some((type) => type.id === this.typeFilter)) {
|
||||
this.typeFilter = '';
|
||||
}
|
||||
this.applyProductFilters();
|
||||
}
|
||||
|
||||
/** Rebuilds the product list from the template: active only, then category and type, then the search text. */
|
||||
applyProductFilters(): void {
|
||||
this.productList = this.allProducts.filter((product: any) => {
|
||||
if (this.activeProductIds && !this.activeProductIds.has(Number(product.id))) {
|
||||
return false;
|
||||
}
|
||||
const ext = this.catalog.classification(product.id);
|
||||
if (this.categoryFilter && ext['productTypeGroup'] !== this.categoryFilter) {
|
||||
return false;
|
||||
}
|
||||
return !this.typeFilter || ext['productTypeSubGroup'] === this.typeFilter;
|
||||
});
|
||||
this.searchItem();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 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 { Injectable, inject } from '@angular/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { catchError, map, tap } from 'rxjs/operators';
|
||||
import { ProductsService } from 'app/products/products.service';
|
||||
|
||||
/**
|
||||
* Read-only view of the loan product catalogue for the loan account screens: which products are active today and
|
||||
* what category and type each one is. Reads the normal product list once; the category and type of each product
|
||||
* come from the classification the list carries (`advanceExtensions`).
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class LoanProductCatalogService {
|
||||
private productsService = inject(ProductsService);
|
||||
|
||||
private byId = new Map<number, any>();
|
||||
|
||||
/**
|
||||
* The products that are active today. Also remembers every product it saw, so `get` and `classification` can
|
||||
* answer for them afterwards. An empty list on failure, so the picker never ends up with nothing to choose.
|
||||
*/
|
||||
activeProducts(): Observable<any[]> {
|
||||
return this.productsService.getLoanProducts().pipe(
|
||||
tap((products: any[]) => (products ?? []).forEach((product) => this.byId.set(Number(product.id), product))),
|
||||
map((products: any[]) => (products ?? []).filter((product) => product?.status === 'loanProduct.active')),
|
||||
catchError(() => of([]))
|
||||
);
|
||||
}
|
||||
|
||||
/** A product seen by `activeProducts`, or undefined before it ran or for an unknown id. */
|
||||
get(productId: any): any {
|
||||
return this.byId.get(Number(productId));
|
||||
}
|
||||
|
||||
/** The product's category and type (`productTypeGroup`, `productTypeSubGroup`, ...), empty when it has none. */
|
||||
classification(productId: any): Record<string, any> {
|
||||
return this.get(productId)?.advanceExtensions ?? {};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user