diff --git a/src/app/loans/loans-account-stepper/loan-product-template-card/loan-product-template-card.component.html b/src/app/loans/loans-account-stepper/loan-product-template-card/loan-product-template-card.component.html new file mode 100644 index 000000000..20170e15f --- /dev/null +++ b/src/app/loans/loans-account-stepper/loan-product-template-card/loan-product-template-card.component.html @@ -0,0 +1,43 @@ + + +@if (product) { +
+
+ {{ product.name }} + {{ category }} + {{ type }} +
+
+
+ Amount + + @if (product.minPrincipal != null || product.maxPrincipal != null) { + {{ product.minPrincipal | number: '1.0-0' }} – {{ product.maxPrincipal | number: '1.0-0' }} + } @else { + — + } + +
+
+ Tenure (repayments) + + @if (product.minNumberOfRepayments != null || product.maxNumberOfRepayments != null) { + {{ product.minNumberOfRepayments }} – {{ product.maxNumberOfRepayments }} + } @else { + — + } + +
+
+ Short name + {{ product.shortName || '—' }} +
+
+
+} diff --git a/src/app/loans/loans-account-stepper/loan-product-template-card/loan-product-template-card.component.scss b/src/app/loans/loans-account-stepper/loan-product-template-card/loan-product-template-card.component.scss new file mode 100644 index 000000000..3e4824e67 --- /dev/null +++ b/src/app/loans/loans-account-stepper/loan-product-template-card/loan-product-template-card.component.scss @@ -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; + } +} diff --git a/src/app/loans/loans-account-stepper/loan-product-template-card/loan-product-template-card.component.ts b/src/app/loans/loans-account-stepper/loan-product-template-card/loan-product-template-card.component.ts new file mode 100644 index 000000000..be03d6c85 --- /dev/null +++ b/src/app/loans/loans-account-stepper/loan-product-template-card/loan-product-template-card.component.ts @@ -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 + ); + } +} diff --git a/src/app/loans/loans-account-stepper/loans-account-details-step/loans-account-details-step.component.ts b/src/app/loans/loans-account-stepper/loans-account-details-step/loans-account-details-step.component.ts index 8d649088c..e60f519d1 100644 --- a/src/app/loans/loans-account-stepper/loans-account-details-step/loans-account-details-step.component.ts +++ b/src/app/loans/loans-account-stepper/loans-account-details-step/loans-account-details-step.component.ts @@ -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 | 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 = new ReplaySubject(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 { diff --git a/src/app/loans/services/loan-product-catalog.service.ts b/src/app/loans/services/loan-product-catalog.service.ts new file mode 100644 index 000000000..907847faf --- /dev/null +++ b/src/app/loans/services/loan-product-catalog.service.ts @@ -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(); + + /** + * 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 { + 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 { + return this.get(productId)?.advanceExtensions ?? {}; + } +} diff --git a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html index 0ec403f4b..35c9f7843 100644 --- a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html +++ b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html @@ -27,7 +27,7 @@
- +
@@ -84,6 +84,27 @@
{{ addProductError }}
+
+
Add amount slab
+
+
+ + +
+
+ + +
+ + +
+
The new slab is added as the last column; fill in its rates for each product.
+
{{ addSlabError }}
+
diff --git a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.scss b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.scss index 476337e6e..25e958ccc 100644 --- a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.scss +++ b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.scss @@ -137,6 +137,12 @@ } } + &__add-hint { + margin-top: 8px; + font-size: 12px; + color: #64748b; + } + &__add-error { margin-top: 8px; font-size: 12px; diff --git a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.ts b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.ts index 95472abde..1900d114c 100644 --- a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.ts +++ b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.ts @@ -227,6 +227,7 @@ export class EditRegisterComponent implements OnInit, OnDestroy { if (!this.canEdit) { return; } + this.addingSlab = false; this.addingProduct = true; this.newProductName = ''; this.newProductGl = null; @@ -271,23 +272,67 @@ export class EditRegisterComponent implements OnInit, OnDestroy { } } - /** Append a new amount slab column; every existing row grows by one blank cell. */ + /** "Add slab" panel state. A slab is an amount band; at least a label or an upper limit is needed. */ + addingSlab = false; + newSlabLabel = ''; + newSlabAmount: number | null = null; + addSlabError = ''; + + /** Opens the Add slab panel. */ addSlab(): void { if (!this.canEdit) { return; } - const label = window.prompt('Slab label (e.g. "Upto 50,00,000" or "> 50,00,000")', 'New slab'); + this.addingProduct = false; + this.addingSlab = true; + this.newSlabLabel = ''; + this.newSlabAmount = null; + this.addSlabError = ''; + } + + cancelAddSlab(): void { + this.addingSlab = false; + this.addSlabError = ''; + } + + /** Label that is filled in for a slab when only the upper limit is given, e.g. "Upto 50,00,000". */ + suggestedSlabLabel(): string { + const amount = this.newSlabAmount; + return amount != null && Number.isFinite(amount) && amount > 0 ? `Upto ${amount.toLocaleString('en-IN')}` : ''; + } + + /** Append the new amount slab as the last column; every existing row grows by one blank cell. */ + confirmAddSlab(): void { + const amount = this.newSlabAmount; + const hasAmount = amount != null && String(amount) !== ''; + if (hasAmount && (!Number.isFinite(amount) || (amount as number) <= 0)) { + this.addSlabError = 'The upper limit must be a number greater than 0.'; + return; + } + const label = this.newSlabLabel.trim() || this.suggestedSlabLabel(); if (!label) { + this.addSlabError = 'Enter a label, or an upper limit to build one from.'; + return; + } + if (this.slabs.some((s) => s.label.trim().toLowerCase() === label.toLowerCase())) { + this.addSlabError = 'A slab with this label already exists.'; + return; + } + if (hasAmount && this.slabs.some((s) => s.amountUpto === amount)) { + this.addSlabError = 'A slab with this upper limit already exists.'; + return; + } + if (!hasAmount && this.slabs.some((s) => s.amountUpto === null)) { + this.addSlabError = 'An open-ended slab (no upper limit) already exists.'; return; } - const amountText = window.prompt('Amount upper bound in ₹ (leave blank for no cap)', ''); - const amountUpto = amountText && amountText.trim() ? Number(amountText) : null; this.slabs.push({ key: `s_${Date.now()}`, - label: label.trim(), - amountUpto: amountUpto !== null && !Number.isFinite(amountUpto) ? null : amountUpto + label, + amountUpto: hasAmount ? (amount as number) : null }); this.rows.forEach((r) => r.rates.push(null)); + this.cancelAddSlab(); } editCibil(): void { diff --git a/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html index dd57f3b14..cf9e44f33 100644 --- a/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html +++ b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html @@ -71,7 +71,7 @@ > - {{ r.name }} + {{ r.name || '(Untitled draft)' }} {{ statusLabel(r.status) }} diff --git a/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.ts b/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.ts index 6614572f5..71e7c00b5 100644 --- a/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.ts +++ b/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.ts @@ -11,6 +11,12 @@ import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; import { MatButtonModule } from '@angular/material/button'; import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatDialog } from '@angular/material/dialog'; +import { + SubmitDetailsData, + SubmitDetailsDialogComponent, + SubmitDetailsResult +} from '../submit-details-dialog/submit-details-dialog.component'; import { RateRegisterService } from '../rate-register.service'; import { CibilSlab, RateChartRow, RateRegister, SlabColumn } from '../models/rate-register.model'; import { RateRegisterDiffComponent } from '../rate-register-diff/rate-register-diff.component'; @@ -41,6 +47,7 @@ export class PreviewRegisterComponent implements OnInit { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly snack = inject(MatSnackBar); + private readonly dialog = inject(MatDialog); draft!: RateRegister; active?: RateRegister; @@ -98,6 +105,33 @@ export class PreviewRegisterComponent implements OnInit { if (this.submitting) { return; } + const ref = this.dialog.open( + SubmitDetailsDialogComponent, + { + width: '520px', + maxWidth: '95vw', + data: { + name: this.draft.name, + effectiveFrom: this.draft.effectiveFrom, + effectiveTo: this.draft.effectiveTo ?? '', + activeName: this.active && this.active.id !== this.draft.id ? this.active.name : undefined, + activeFrom: this.active && this.active.id !== this.draft.id ? this.active.effectiveFrom : undefined + } + } + ); + ref.afterClosed().subscribe((details) => { + if (!details) { + return; + } + this.draft.name = details.name; + this.draft.effectiveFrom = details.effectiveFrom; + this.draft.effectiveTo = details.effectiveTo || null; + this.service.saveDrafts(); + this.send(); + }); + } + + private send(): void { this.submitting = true; this.service.submitForApproval(this.draft.id).subscribe({ next: (res) => { diff --git a/src/app/products/loan-interest-rate-register/rate-register.service.ts b/src/app/products/loan-interest-rate-register/rate-register.service.ts index 9230ad7a5..16dfa332c 100644 --- a/src/app/products/loan-interest-rate-register/rate-register.service.ts +++ b/src/app/products/loan-interest-rate-register/rate-register.service.ts @@ -425,9 +425,10 @@ export class RateRegisterService { const today = new Date().toISOString().slice(0, 10); const copy: RateRegister = { id: newId, - name: `${source.name} — new version`, - effectiveFrom: source.effectiveFrom, - effectiveTo: source.effectiveTo, + // Left blank on purpose: the maker enters name and dates in the submit popup. + name: '', + effectiveFrom: '', + effectiveTo: null, currency: source.currency, status: 'Draft', createdBy: 'Current User', diff --git a/src/app/products/loan-interest-rate-register/submit-details-dialog/submit-details-dialog.component.ts b/src/app/products/loan-interest-rate-register/submit-details-dialog/submit-details-dialog.component.ts new file mode 100644 index 000000000..e815d80f3 --- /dev/null +++ b/src/app/products/loan-interest-rate-register/submit-details-dialog/submit-details-dialog.component.ts @@ -0,0 +1,139 @@ +/** + * 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 { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; + +export interface SubmitDetailsData { + name: string; + effectiveFrom: string; + effectiveTo: string; + /** Active version this one replaces, shown as context. */ + activeName?: string; + activeFrom?: string; +} + +export interface SubmitDetailsResult { + name: string; + effectiveFrom: string; + effectiveTo: string; +} + +/** Asks the maker for the version details (name, effective from / to) just before sending for approval. */ +@Component({ + selector: 'mifosx-submit-details-dialog', + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatButtonModule + ], + template: ` +

Version details

+ +

+ Confirm these details before sending the rate register for approval. + It will replace {{ data.activeName }} (effective {{ data.activeFrom }}) once approved. +

+
+ + +
+
+
+ + +
+
+ + +
+
+
{{ error }}
+
+ + + + + `, + styles: [ + ` + .sd { + min-width: 460px; + display: flex; + flex-direction: column; + gap: 12px; + } + .sd__hint { + margin: 0; + font-size: 12px; + color: #64748b; + } + .sd__row { + display: flex; + gap: 12px; + } + .sd__field { + display: flex; + flex-direction: column; + flex: 1; + } + .sd__field label { + font-size: 10px; + font-weight: 600; + color: #64748b; + letter-spacing: 0.3px; + margin-bottom: 3px; + } + .sd__field input { + height: 34px; + border: 1px solid #cbd5e1; + border-radius: 6px; + padding: 0 10px; + font-size: 13px; + font-family: inherit; + } + .sd__error { + font-size: 12px; + color: #b91c1c; + } + ` + ] +}) +export class SubmitDetailsDialogComponent { + readonly ref = inject>(MatDialogRef); + readonly data = inject(MAT_DIALOG_DATA); + + name = this.data.name; + effectiveFrom = this.data.effectiveFrom; + effectiveTo = this.data.effectiveTo; + error = ''; + + confirm(): void { + const name = this.name.trim(); + if (!name) { + this.error = 'Enter the register name.'; + return; + } + if (!this.effectiveFrom) { + this.error = 'Select the effective from date.'; + return; + } + if (this.effectiveTo && this.effectiveTo < this.effectiveFrom) { + this.error = 'Effective to cannot be before effective from.'; + return; + } + this.ref.close({ name, effectiveFrom: this.effectiveFrom, effectiveTo: this.effectiveTo }); + } +} diff --git a/src/app/products/loan-products/create-loan-product/create-loan-product.component.html b/src/app/products/loan-products/create-loan-product/create-loan-product.component.html index abc3da5c8..d900c8414 100644 --- a/src/app/products/loan-products/create-loan-product/create-loan-product.component.html +++ b/src/app/products/loan-products/create-loan-product/create-loan-product.component.html @@ -75,7 +75,10 @@ INTEREST
- +
@@ -114,6 +117,77 @@ } } + @if (isLoanAccount) { + + + + SECURITY & COLLATERAL + +
+ +
+
+ + + + + AMOUNT & TENURE + +
+ +
+
+ + + + + REPAYMENT & EMI + +
+ +
+
+ + + + + INTEREST + +
+ +
+
+ + + + + PENAL & NPA + +
+ +
+
+ + + + + PREPAYMENT & CLOSURE + +
+ +
+
+ } + + @@ -193,6 +268,7 @@ + diff --git a/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts b/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts index 50c14db3b..005e57c45 100644 --- a/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts +++ b/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts @@ -24,6 +24,11 @@ import { LoanProductSettingsStepComponent } from '../loan-product-stepper/loan-p import { LoanProductChargesStepComponent } from '../loan-product-stepper/loan-product-charges-step/loan-product-charges-step.component'; import { LoanProductAccountingStepComponent } from '../loan-product-stepper/loan-product-accounting-step/loan-product-accounting-step.component'; import { LoanProductInterestRefundStepComponent } from '../loan-product-stepper/loan-product-interest-refund-step/loan-product-interest-refund-step.component'; +import { LoanProductLoanSecurityStepComponent } from '../loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component'; +import { LoanProductLoanAmountTenureStepComponent } from '../loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component'; +import { LoanProductLoanRepaymentStepComponent } from '../loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component'; +import { LoanProductLoanInterestStepComponent } from '../loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component'; +import { LoanProductLoanPrepaymentStepComponent } from '../loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component'; /** Custom Services */ import { ProductsService } from 'app/products/products.service'; @@ -63,12 +68,18 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; LoanProductPenalNpaStepComponent, LoanProductRenewalClosureStepComponent, LoanProductChequeBookStepComponent, - LoanProductSettingsStepComponent, - LoanProductInterestRefundStepComponent, - LoanProductPaymentStrategyStepComponent, - LoanProductTermsStepComponent, + LoanProductLoanSecurityStepComponent, + LoanProductLoanAmountTenureStepComponent, + LoanProductLoanRepaymentStepComponent, + LoanProductLoanInterestStepComponent, + LoanProductLoanPrepaymentStepComponent, + // Old Loans A/C path — commented out for now (its tabs are commented out in the template): + // LoanProductSettingsStepComponent, + // LoanProductInterestRefundStepComponent, + // LoanProductPaymentStrategyStepComponent, + // LoanProductTermsStepComponent, + // LoanProductDeferredIncomeRecognitionStepComponent, LoanProductChargesStepComponent, - LoanProductDeferredIncomeRecognitionStepComponent, LoanProductAccountingStepComponent, LoanProductPreviewStepComponent ] @@ -97,6 +108,18 @@ export class CreateLoanProductComponent implements OnInit { loanProductRenewalClosureStep: LoanProductRenewalClosureStepComponent; @ViewChild(LoanProductChequeBookStepComponent) loanProductChequeBookStep: LoanProductChequeBookStepComponent; + // Conditional tabs (Loans A/C only): resolved after the @if renders them. + @ViewChild(LoanProductLoanSecurityStepComponent) + loanProductLoanSecurityStep: LoanProductLoanSecurityStepComponent; + @ViewChild(LoanProductLoanAmountTenureStepComponent) + loanProductLoanAmountTenureStep: LoanProductLoanAmountTenureStepComponent; + @ViewChild(LoanProductLoanRepaymentStepComponent) + loanProductLoanRepaymentStep: LoanProductLoanRepaymentStepComponent; + @ViewChild(LoanProductLoanInterestStepComponent) + loanProductLoanInterestStep: LoanProductLoanInterestStepComponent; + @ViewChild(LoanProductLoanPrepaymentStepComponent) + loanProductLoanPrepaymentStep: LoanProductLoanPrepaymentStepComponent; + /** Charges step needs a real control; Settings is absent on the Advances path, so fall back to this. */ readonly multiDisburseFallback = new UntypedFormControl(false); @ViewChild(LoanProductInterestRefundStepComponent, { static: true }) @@ -179,6 +202,11 @@ export class CreateLoanProductComponent implements OnInit { return this.isAdvanceProduct && !!this.loanProductAdvanceLimitTenureStep?.isOverdraft; } + /** The GL account chosen on the Details step; the rate register row can be matched by it. */ + get productGlAccount(): number | string | null { + return this.loanProductDetailsStep?.loanProductDetailsForm?.get('glAccount')?.value ?? null; + } + /** Sub type chosen on the Details step; drives the FD 12–15 month tenure policy downstream. */ get advanceSubType(): string { return this.loanProductDetailsStep?.loanProductDetailsForm?.get('productTypeSubGroup')?.value ?? ''; @@ -282,16 +310,26 @@ export class CreateLoanProductComponent implements OnInit { return this.loanProductDetailsForm?.get('productTypeGroup')?.value === 'advances'; } + /** The Details step's chosen product type; drives the Loans A/C (EMI) tabs. */ + get isLoanAccount(): boolean { + return this.loanProductDetailsForm?.get('productTypeGroup')?.value === 'loans'; + } + get totalTabs(): number { let tabs = 4; // details, currency, charges, accounting (always) if (this.isAdvanceProduct) { // security & lien, limit & tenure, interest, penal & npa, renewal & closure tabs += 5; if (this.isOverdraftFacility) tabs += 1; // cheque book - } else { - tabs += 2; // settings, terms - if (this.isAdvancedPaymentStrategy) tabs += 3; // interest refund, payment allocation, deferred income + } else if (this.isLoanAccount) { + // security & collateral, amount & tenure, repayment & emi, interest, penal & npa, prepayment & closure + tabs += 6; } + // Old Loans A/C path (Settings, Terms, Interest Refund, Payment Allocation, Deferred Income) — commented out: + // else { + // tabs += 2; // settings, terms + // if (this.isAdvancedPaymentStrategy) tabs += 3; // interest refund, payment allocation, deferred income + // } if (this.loanProductFormValid) tabs += 1; // preview return tabs; } @@ -313,12 +351,28 @@ export class CreateLoanProductComponent implements OnInit { ); } - // Loans A/C: original Settings + Terms path. - const loansValid = !!this.loanProductTermsForm?.valid && !!this.loanProductSettingsForm?.valid; - if (this.isAdvancedPaymentStrategy) { - return base && loansValid && !!this.loanIncomeCapitalizationForm?.valid; + // Loans A/C (EMI): the curated Loans steps gate the form; Settings/Terms tabs are commented out. + if (this.isLoanAccount) { + return ( + base && + !!this.loanProductLoanSecurityStep?.loanProductLoanSecurityForm?.valid && + !!this.loanProductLoanAmountTenureStep?.loanProductLoanAmountTenureForm?.valid && + !!this.loanProductLoanRepaymentStep?.loanProductLoanRepaymentForm?.valid && + !!this.loanProductLoanInterestStep?.loanProductLoanInterestForm?.valid && + !!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid && + !!this.loanProductLoanPrepaymentStep?.loanProductLoanPrepaymentForm?.valid + ); } - return base && loansValid; + + // No product type chosen yet. + return false; + + // Old Loans A/C path (Settings + Terms) — commented out for now: + // const loansValid = !!this.loanProductTermsForm?.valid && !!this.loanProductSettingsForm?.valid; + // if (this.isAdvancedPaymentStrategy) { + // return base && loansValid && !!this.loanIncomeCapitalizationForm?.valid; + // } + // return base && loansValid; } get loanProduct() { @@ -328,7 +382,13 @@ export class CreateLoanProductComponent implements OnInit { return this.assembleAdvanceProduct(); } - // Loans A/C: original composition (Settings/Terms present). + // Loans A/C (EMI): builds its own payload from the Loans steps, mapped onto Fineract's core fields. + if (this.isLoanAccount) { + return this.assembleLoanProduct(); + } + return {}; + + /* OLD LOANS A/C PATH (Settings/Terms composition) — commented out for now. const loanProduct = { ...this.loanProductDetailsStep.loanProductDetails, ...this.loanProductCurrencyStep.loanProductCurrency, @@ -364,6 +424,181 @@ export class CreateLoanProductComponent implements OnInit { } } return loanProduct; + */ + } + + /** + * Builds the create payload for a Loans A/C (EMI) product: the Loans steps mapped onto Fineract's core + * product fields (so the standard product saves). The Loans-only options that have no core field yet + * (eligibility, collateral rules, prepayment, repayment mode, floating reset, register link) stay UI-only + * and are not sent — an unknown key would be rejected by checkForUnsupportedParameters. + */ + private assembleLoanProduct(): any { + const num = (v: any): number | undefined => (v === '' || v === null || v === undefined ? undefined : Number(v)); + + const d = this.loanProductDetailsStep.loanProductDetails; + const cur = this.loanProductCurrencyStep.loanProductCurrency; + const at = this.loanProductLoanAmountTenureStep.loanProductLoanAmountTenure; + const rp = this.loanProductLoanRepaymentStep.loanProductLoanRepayment; + const intr = this.loanProductLoanInterestStep.loanProductLoanInterest; + const sec = this.loanProductLoanSecurityStep.loanProductLoanSecurity; + const pp = this.loanProductLoanPrepaymentStep.loanProductLoanPrepayment; + const pn = this.loanProductPenalNpaStep.loanProductPenalNpa; + + const stepper = this.loanProductLoanRepaymentStep; + const interestStep = this.loanProductLoanInterestStep; + const frequency = stepper.frequencyOptions.find((f) => f.id === rp.frequency)?.fineractId ?? 2; + const daysInYear = interestStep.daysInYearOptions.find((o) => o.id === intr.daysInYear)?.fineractId ?? 1; + const daysInMonth = interestStep.daysInMonthOptions.find((o) => o.id === intr.daysInMonth)?.fineractId ?? 1; + + const defaultStrategy = this.loanProductsTemplate?.transactionProcessingStrategyOptions?.[0]?.code; + const minTenure = num(at.minTenure); + const minRate = num(intr.minRate); + const multiTranche = at.disbursementType === 'multiple'; + const moratorium = num(rp.moratoriumMonths); + + const payload: any = { + // identity + currency + name: d.name, + shortName: d.shortName, + description: d.description, + startDate: d.startDate, + closeDate: d.closeDate, + currencyCode: cur.currencyCode, + digitsAfterDecimal: cur.digitsAfterDecimal, + inMultiplesOf: cur.inMultiplesOf ?? 1, + + // amount -> principal + minPrincipal: num(at.minAmount), + principal: num(at.defaultAmount) ?? num(at.minAmount), + maxPrincipal: num(at.maxAmount), + + // tenure (months) -> number of repayments + minNumberOfRepayments: minTenure, + numberOfRepayments: num(at.defaultTenure) ?? minTenure, + maxNumberOfRepayments: num(at.maxTenure), + repaymentEvery: num(rp.repaidEvery) ?? 1, + repaymentFrequencyType: frequency, + graceOnPrincipalPayment: moratorium && moratorium > 0 ? moratorium : undefined, + + // rate + minInterestRatePerPeriod: minRate, + interestRatePerPeriod: num(intr.defaultRate) ?? minRate, + maxInterestRatePerPeriod: num(intr.maxRate), + interestRateFrequencyType: 3, // per year + + // amortization / calculation + amortizationType: rp.repaymentType === 'equal_principal' ? 0 : 1, // 0 equal principal, 1 equal installments + interestType: intr.calcMethod === 'flat' ? 1 : 0, // 0 declining balance, 1 flat + interestCalculationPeriodType: intr.calcPeriod === 'daily' ? 0 : 1, // 0 daily, 1 same as repayment period + transactionProcessingStrategyCode: defaultStrategy, + daysInYearType: daysInYear, + daysInMonthType: daysInMonth, + isInterestRecalculationEnabled: false, + + // disbursement + multiDisburseLoan: multiTranche, + maxTrancheCount: multiTranche ? num(at.maxTranches) : undefined, + + // NPA / delinquency map onto core product fields + overdueDaysForNPA: num(pn.npaDays), + graceOnArrearsAgeing: num(pn.graceOnArrearsAgeing), + inArrearsTolerance: num(pn.inArrearsTolerance), + delinquencyBucketId: pn.delinquencyBucketId || undefined, + + // accounting rule + GL mappings + charges (all standard, whitelisted) + ...this.loanProductAccountingStep.loanProductAccounting, + ...this.loanProductChargesStep.loanProductCharges, + + // Details-step satellite params (same whitelist the Advances path uses) + productTypeGroup: d.productTypeGroup, + productTypeSubGroup: d.productTypeSubGroup, + glAccount: d.glAccount || undefined, + loanProductCategory: d.loanProductCategory, + prioritySector: d.prioritySector, + cibilAccountType: d.cibilAccountType, + kfsEnabled: d.kfsEnabled, + aprEnabled: d.aprEnabled, + + // Loans A/C (EMI) fields kept on the product satellite (same whitelist as the Advances fields) + margin: num(sec.margin), + ltv: sec.ltv, + securityType: sec.securityType, + repaymentType: rp.repaymentType, + appropriationOrder: rp.appropriationOrder, + interestCalcMethod: intr.calcMethod, + spread: num(intr.spread), + lockInMonths: num(pp.lockInMonths), + interestOnPreclosure: pp.interestOnPreclosure, + penalApplicable: pn.penalApplicable, + penalRate: num(pn.penalRate), + penalAppliedOn: pn.penalAppliedOn, + penalGraceDays: num(pn.penalGraceDays), + npaTrigger: pn.npaTrigger, + interestAfterNpa: pn.interestAfterNpa, + npaUpgradeOnlyAllArrears: pn.upgradeOnlyAllArrears, + npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers, + securityMandatory: sec.securityMandatory, + guarantorsRequired: num(sec.guarantorsRequired), + coApplicant: sec.coApplicant, + thirdPartySecurity: sec.thirdPartySecurity, + legalOpinionRequired: sec.legalOpinionRequired, + propertyInsuranceRequired: sec.propertyInsuranceRequired, + registerCharge: sec.registerCharge, + releaseSecurityOnClosure: sec.autoReleaseOnClosure, + allowMultipleSecurities: sec.allowMultipleSecurities, + allowThirdPartyDeposit: sec.allowThirdPartyDeposit, + minAge: num(at.minAge), + maxAgeAtMaturity: num(at.maxAgeAtMaturity), + minCibil: num(at.minCibil), + minMonthlyIncome: num(at.minMonthlyIncome), + maxFoir: num(at.maxFoir), + maxLoansPerCustomer: num(at.maxLoansPerCustomer), + disbursementType: at.disbursementType, + disbursementMode: at.disbursementMode, + undisbursedInterest: at.undisbursedInterest, + preEmiInterest: at.preEmiInterest, + emiDueDay: rp.emiDueDay, + holidayRule: rp.holidayRule, + moratoriumMonths: num(rp.moratoriumMonths), + moratoriumInterest: rp.moratoriumInterest, + variableInstallments: rp.variableInstallments, + repaymentMode: rp.repaymentMode, + linkedAccountType: rp.linkedAccountType, + pdcCollection: rp.pdcCollection, + mandateRequired: rp.mandateRequired, + advanceEmi: rp.advanceEmi, + rateType: intr.interestType, + registerParticular: intr.registerParticular, + applyCibilAdjustment: intr.applyCibilAdjustment, + rateResolvedAt: intr.rateResolvedAt, + calcPeriod: intr.calcPeriod, + interestStartsFrom: intr.interestStartsFrom, + compounding: intr.compounding, + benchmark: intr.benchmark, + resetFrequency: intr.resetFrequency, + resetChange: intr.resetChange, + partPrepaymentAllowed: pp.partPrepaymentAllowed, + minPartPayment: num(pp.minPartPayment), + allowedOn: pp.allowedOn, + partPaymentsPerYear: pp.partPaymentsPerYear, + partPrepaymentMode: pp.partPrepaymentMode, + foreclosureFloatingIndividual: num(pp.foreclosureFloatingIndividual), + foreclosureFixedIndividual: num(pp.foreclosureFixedIndividual), + foreclosureNonIndividual: num(pp.foreclosureNonIndividual), + foreclosureTakeover: num(pp.foreclosureTakeover), + foreclosureQuoteValidDays: num(pp.foreclosureQuoteValidDays), + autoCloseOnZeroBalance: pp.autoCloseOnZeroBalance, + nocLetter: pp.nocLetter, + restructuring: pp.restructuring, + releaseCollateralOnClosure: pp.releaseCollateralOnClosure, + topUpAllowed: pp.topUpAllowed, + balanceTransferOutAllowed: pp.balanceTransferOutAllowed + }; + + // Drop undefined keys so we never send an unresolved value. + Object.keys(payload).forEach((k) => payload[k] === undefined && delete payload[k]); + return payload; } /** @@ -413,7 +648,8 @@ export class CreateLoanProductComponent implements OnInit { // rate minInterestRatePerPeriod: minRate, - interestRatePerPeriod: minRate, + // A deposit-linked row ("1% + FD") has no plain minimum or maximum, so the register step supplies the default. + interestRatePerPeriod: num(intr.defaultRate) ?? minRate, maxInterestRatePerPeriod: num(intr.maxRate), interestRateFrequencyType: 3, // per year @@ -457,6 +693,7 @@ export class CreateLoanProductComponent implements OnInit { lienCertificatePrefix: sec.lienCertificatePrefix, printCertificateOnDisbursement: sec.printOnDisbursement, interestBasis: intr.interestBasis, + registerParticular: intr.registerParticular || undefined, spread: num(intr.spread), interestChargedOn: intr.interestChargedOn, interestPostingFrequency: intr.postingFrequency, diff --git a/src/app/products/loan-products/loan-product-stepper/advance-steps.scss b/src/app/products/loan-products/loan-product-stepper/advance-steps.scss index 29d9a6b7e..062f2f8fe 100644 --- a/src/app/products/loan-products/loan-product-stepper/advance-steps.scss +++ b/src/app/products/loan-products/loan-product-stepper/advance-steps.scss @@ -172,3 +172,45 @@ $gray-900: #202124; color: #075985; line-height: 1.5; } + +/* Read-only reference tables (Loans A/C steps): collateral rules, foreclosure charges. */ +.adv-table { + width: 100%; + border-collapse: collapse; + + th { + text-align: left; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #475569; + padding: 12px 24px; + background: $gray-50; + border-bottom: 1px solid $border; + } + + td { + font-size: 14px; + padding: 10px 24px; + border-bottom: 1px solid #eef2f6; + color: #1e293b; + } + + .adv-table__no { + color: #94a3b8; + } + + .adv-table__yes { + color: #047857; + font-weight: 600; + } +} + +.adv-type-options--3 { + grid-template-columns: repeat(3, 1fr); + + @media (width <= 900px) { + grid-template-columns: 1fr; + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.html index d81f96fbf..2c616c118 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.html +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.html @@ -11,36 +11,14 @@

Rate

+ Taken from the Loan Interest Rate Register
-
- - Interest Basis - - @for (opt of interestBasisOptions; track opt.id) { - {{ opt.label }} - } - - - @if (showSpread) { - - Spread over Deposit Rate (%) - - Advance rate = deposit rate + spread - - } @else { -
- } -
- - Minimum Rate (% p.a.) - - Minimum Rate is required - - - Maximum Rate (% p.a.) - - Maximum Rate is required - +
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.ts index 89dbb9956..4aa3d2dda 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.ts @@ -6,9 +6,10 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, inject } from '@angular/core'; +import { Component, Input, inject } from '@angular/core'; import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { RegisterRateSelection, RegisterRateViewComponent } from '../register-rate-view/register-rate-view.component'; /** * Interest step for an Advances A/C product (Figma step 5). Interest basis + spread, rate bounds, @@ -18,13 +19,23 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; selector: 'mifosx-loan-product-advance-interest-step', templateUrl: './loan-product-advance-interest-step.component.html', styleUrls: ['../advance-steps.scss'], - imports: [...STANDALONE_SHARED_IMPORTS] + imports: [ + ...STANDALONE_SHARED_IMPORTS, + RegisterRateViewComponent + ] }) export class LoanProductAdvanceInterestStepComponent { private formBuilder = inject(UntypedFormBuilder); loanProductAdvanceInterestForm: UntypedFormGroup; + /** Sub type and GL account picked on the Details step; they choose the Loan Interest Rate Register row. */ + @Input() productSubType = ''; + @Input() glAccountId: number | string | null = null; + + /** The register row the product takes its rate from; null while the register has no row for it. */ + registerSelection: RegisterRateSelection | null = null; + readonly interestBasisOptions = [ { id: 'deposit_spread', label: 'Deposit Rate + Spread' }, { id: 'fixed', label: 'Fixed rate' }, @@ -77,11 +88,12 @@ export class LoanProductAdvanceInterestStepComponent { Validators.max(20) ] ], - minRate: [ - '', - Validators.required - ], - maxRate: [ + // Filled from the register row; never typed in. + minRate: [''], + maxRate: [''], + defaultRate: [''], + // The rate comes from the Loan Interest Rate Register, so a product with no register row cannot be created. + registerParticular: [ '', Validators.required ], @@ -95,6 +107,33 @@ export class LoanProductAdvanceInterestStepComponent { }); } + /** + * The register row decides the rate. A row of plain rates fixes the minimum and maximum; a deposit row + * ("1% + FD") is the deposit rate plus a spread, which has no plain minimum or maximum. With no row for the + * product the register particular stays empty, which keeps this step, and so the whole product, invalid until + * the product has been added to the register. + */ + onRegisterSelection(selection: RegisterRateSelection | null): void { + this.registerSelection = selection; + const form = this.loanProductAdvanceInterestForm; + if (selection) { + form.patchValue( + { + interestBasis: selection.isFormula ? 'deposit_spread' : 'fixed', + spread: selection.isFormula ? selection.minSpread : 0, + minRate: selection.minRate ?? '', + maxRate: selection.maxRate ?? '', + defaultRate: selection.defaultRate ?? '', + registerParticular: selection.particular + }, + { emitEvent: false } + ); + } else { + form.patchValue({ minRate: '', maxRate: '', defaultRate: '', registerParticular: '' }, { emitEvent: false }); + } + form.updateValueAndValidity({ emitEvent: false }); + } + get showSpread(): boolean { return this.loanProductAdvanceInterestForm.get('interestBasis').value === 'deposit_spread'; } diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts index 57a5f6dc4..6eb65abd2 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts @@ -17,6 +17,7 @@ import { MatTooltip } from '@angular/material/tooltip'; import { CdkTextareaAutosize } from '@angular/cdk/text-field'; import { MatRadioButton, MatRadioGroup } from '@angular/material/radio'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS } from '../../loan-product-types'; @Component({ selector: 'mifosx-loan-product-details-step', @@ -110,35 +111,10 @@ export class LoanProductDetailsStepComponent implements OnInit { isExistingProduct = false; /** Product Type -- picked first, since it decides which sections the rest of the wizard shows. */ - readonly productTypeGroups = [ - { id: 'advances', label: 'Advances A/C', hint: 'No EMI. Limit / drawing power against security.' }, - { id: 'loans', label: 'Loans A/C', hint: 'Fixed disbursement, fixed tenure, EMI repayment.' } - ]; + /** Category and type lists are shared with the product list and the loan account product picker. */ + readonly productTypeGroups = PRODUCT_TYPE_GROUPS; - /** `shortName` is the two-letter code the product's Short Name is pre-filled with when the sub type is picked. */ - readonly productTypeSubGroups: Record = { - advances: [ - { id: 'fd', label: 'Loan against FD', shortName: 'FD' }, - { id: 'rd', label: 'Loan against RD', shortName: 'RD' }, - { id: 'dds', label: 'Loan against DDS', shortName: 'DS' }, - { id: 'lic', label: 'Loan against LIC Policy', shortName: 'LI' }, - { id: 'gold', label: 'Loan against Gold', shortName: 'GD' }, - { id: 'warehouse', label: 'Warehouse Receipt', shortName: 'WR' }, - { id: 'cc', label: 'Cash Credit (CC)', shortName: 'CC' }, - { id: 'overdraft', label: 'Overdraft (OD)', shortName: 'OD' }, - { id: 'kcc', label: 'Kisan Credit Card (KCC)', shortName: 'KC' }, - { id: 'bills', label: 'Bills Purchased / Discounted', shortName: 'BP' } - ], - loans: [ - { id: 'term', label: 'Term Loan', shortName: 'TL' }, - { id: 'personal', label: 'Personal Loan', shortName: 'PL' }, - { id: 'housing', label: 'Housing Loan', shortName: 'HL' }, - { id: 'vehicle', label: 'Vehicle Loan', shortName: 'VL' }, - { id: 'education', label: 'Education Loan', shortName: 'EL' }, - { id: 'business', label: 'Business / MSME Loan', shortName: 'BL' }, - { id: 'gold_emi', label: 'Gold EMI Loan', shortName: 'GE' } - ] - }; + readonly productTypeSubGroups = PRODUCT_SUB_TYPES; /** Regulatory Classification options (UI-only; RBI / bureau reporting). */ readonly loanProductCategoryOptions = [ diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.html new file mode 100644 index 000000000..72b1d72d7 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.html @@ -0,0 +1,150 @@ + + +
+
+
+ +

Loan Amount

+
+
+ + Minimum Amount (₹) + + Minimum Amount is required + + + Default Amount (₹) + + + + Maximum Amount (₹) + + Maximum Amount is required + +
+
+
+ Each loan is sanctioned within these bounds. The amount is fixed at sanction. It is not a limit that can be + drawn and repaid again. +
+
+
+ +
+
+ +

Tenure

+
+
+ + Minimum Tenure (months) + + Minimum Tenure is required + + + Default Tenure (months) + + + + Maximum Tenure (months) + + Age at maturity cap comes from Borrower Eligibility below + Maximum Tenure is required + +
+
+ +
+
+ +

Borrower Eligibility

+
+
+ + Minimum Age (years) + + + + Maximum Age at Maturity (years) + + + + Minimum CIBIL Score + + + + Minimum Monthly Income (₹) + + + + Maximum FOIR / DBR (%) + + + + Maximum Loans per Customer + + +
+
+ +
+
+ +

Disbursement

+
+ + + Single disbursement + Full amount paid out once. + + + Multiple tranches + Paid out in stages, for example as construction progresses. + + +
+ @if (isMultiTranche) { + + Maximum Tranches + + At least 2 tranches + + } + + Disbursement Mode + + @for (opt of disbursementModeOptions; track opt.id) { + {{ opt.label }} + } + + + + Interest on Undisbursed Amount + + @for (opt of undisbursedInterestOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ + Pre-EMI interest is charged on each disbursed tranche until the first full EMI + +
+
+ +
+ + + Hidden for Loans A/C: Facility Type (Demand / Overdraft), Drawing power, Limit renewal, Cheque + book. + +
+ diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.ts new file mode 100644 index 000000000..ff547810f --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.ts @@ -0,0 +1,106 @@ +/** + * 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 { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatRadioButton, MatRadioGroup } from '@angular/material/radio'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Amount & Tenure step for a Loans A/C (EMI) product (step 4): fixed sanctioned amount, tenure, + * borrower eligibility and disbursement. Amount, tenure and tranches feed Fineract's core product + * fields; eligibility is UI-only for now. + */ +@Component({ + selector: 'mifosx-loan-product-loan-amount-tenure-step', + templateUrl: './loan-product-loan-amount-tenure-step.component.html', + styleUrls: ['../advance-steps.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatCheckbox, + MatRadioGroup, + MatRadioButton + ] +}) +export class LoanProductLoanAmountTenureStepComponent { + private formBuilder = inject(UntypedFormBuilder); + + loanProductLoanAmountTenureForm: UntypedFormGroup; + + readonly disbursementModeOptions = [ + { id: 'transfer_dd_vendor', label: 'Transfer to A/C, DD, Direct to vendor' }, + { id: 'transfer', label: 'Transfer to A/C only' }, + { id: 'dd', label: 'Demand draft only' } + ]; + + readonly undisbursedInterestOptions = [ + { id: 'none', label: 'None — interest only on disbursed' }, + { id: 'commitment', label: 'Commitment charge on undisbursed' } + ]; + + constructor() { + this.loanProductLoanAmountTenureForm = this.formBuilder.group({ + minAmount: [ + '', + [ + Validators.required, + Validators.min(0) + ] + ], + defaultAmount: [''], + maxAmount: [ + '', + [ + Validators.required, + Validators.min(0) + ] + ], + minTenure: [ + 12, + [ + Validators.required, + Validators.min(1) + ] + ], + defaultTenure: [ + '', + Validators.min(1) + ], + maxTenure: [ + 360, + [ + Validators.required, + Validators.min(1) + ] + ], + minAge: [21], + maxAgeAtMaturity: [70], + minCibil: [650], + minMonthlyIncome: [''], + maxFoir: [50], + maxLoansPerCustomer: [1], + disbursementType: ['single'], + maxTranches: [ + 2, + Validators.min(2) + ], + disbursementMode: ['transfer_dd_vendor'], + undisbursedInterest: ['none'], + preEmiInterest: [true] + }); + } + + get isMultiTranche(): boolean { + return this.loanProductLoanAmountTenureForm.get('disbursementType').value === 'multiple'; + } + + get loanProductLoanAmountTenure() { + return this.loanProductLoanAmountTenureForm.getRawValue(); + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component.html new file mode 100644 index 000000000..0a08d2380 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component.html @@ -0,0 +1,161 @@ + + +
+
+
+ +

Interest Type

+
+ + + Fixed + Same rate for the whole tenure + + + Floating + Linked to a benchmark; resets periodically + + + Hybrid + Fixed for a period, then floating + + +
+
+ +
+
+ +

Rate Source

+ Centralised — changes flow to all loans on this product +
+
+ +
+
+ + Apply CIBIL Adjustment + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + + + Spread over Register Rate (%) + + + + Rate Resolved At + + @for (opt of rateResolvedAtOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ +
+
+ +

Calculation

+
+
+ + Calculation Method + + @for (opt of calcMethodOptions; track opt.id) { + {{ opt.label }} + } + + + + Days in Year + + @for (opt of daysInYearOptions; track opt.id) { + {{ opt.label }} + } + + + + Days in Month + + @for (opt of daysInMonthOptions; track opt.id) { + {{ opt.label }} + } + + + + Calculation Period + + @for (opt of calcPeriodOptions; track opt.id) { + {{ opt.label }} + } + + + + Interest Starts From + + @for (opt of interestStartOptions; track opt.id) { + {{ opt.label }} + } + + + + Compounding + + @for (opt of compoundingOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ + @if (isFloatingOrHybrid) { +
+
+ +

Floating Rate Reset

+ Shown only when the type is Floating or Hybrid +
+
+ + Benchmark + + @for (opt of benchmarkOptions; track opt.id) { + {{ opt.label }} + } + + + + Reset Frequency + + @for (opt of resetFrequencyOptions; track opt.id) { + {{ opt.label }} + } + + + + On Reset, Change + + @for (opt of resetChangeOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ } + diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component.ts new file mode 100644 index 000000000..a29bc3534 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component.ts @@ -0,0 +1,188 @@ +/** + * 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, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { MatRadioButton, MatRadioGroup } from '@angular/material/radio'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { RegisterRateSelection, RegisterRateViewComponent } from '../register-rate-view/register-rate-view.component'; + +/** + * Interest step for a Loans A/C (EMI) product (step 6). The rate normally comes from the central + * Loan Interest Rate Register (amount slab + CIBIL adjustment); the min / default / max rate and the + * calculation method feed Fineract's core product fields. Register link, spread and floating reset + * are UI-only for now. + */ +@Component({ + selector: 'mifosx-loan-product-loan-interest-step', + templateUrl: './loan-product-loan-interest-step.component.html', + styleUrls: ['../advance-steps.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatRadioGroup, + MatRadioButton, + RegisterRateViewComponent + ] +}) +export class LoanProductLoanInterestStepComponent { + private formBuilder = inject(UntypedFormBuilder); + + loanProductLoanInterestForm: UntypedFormGroup; + + /** Sub type and GL account picked on the Details step; they choose the Loan Interest Rate Register row. */ + @Input() productSubType = ''; + @Input() glAccountId: number | string | null = null; + + /** The register row the product takes its rate from; null while the register has no row for it. */ + registerSelection: RegisterRateSelection | null = null; + + /** The 16 default particulars of the Loan Interest Rate Register. */ + readonly registerParticulars = [ + 'Housing Loan (Purchase/Construction)', + 'Home Repair/Renovation', + 'Personal Loan (Unsecured)', + 'Personal Loan (Secured/Property)', + 'Two Wheeler Loan', + 'Car Loan (New)', + 'Used Car/Vehicle Loan', + 'Commercial Vehicle Loan', + 'Gold Loan', + 'Business Term Loan', + 'Machinery & Equipment Loan', + 'Cash Credit', + 'Overdraft', + 'Agriculture Loan', + 'Loan against FDR', + 'Loan against RD' + ]; + + readonly yesNoOptions = [ + 'YES', + 'NO' + ]; + + readonly rateResolvedAtOptions = [ + { id: 'sanction', label: 'Sanction' }, + { id: 'disbursement', label: 'First disbursement' } + ]; + + readonly calcMethodOptions = [ + { id: 'declining', label: 'Declining Balance' }, + { id: 'flat', label: 'Flat' } + ]; + + /** `fineractId` maps to Fineract's daysInYearType (1 = actual). */ + readonly daysInYearOptions = [ + { id: 'actual', label: 'Actual (365 / 366)', fineractId: 1 }, + { id: '365', label: '365', fineractId: 365 }, + { id: '360', label: '360', fineractId: 360 } + ]; + + /** `fineractId` maps to Fineract's daysInMonthType (1 = actual). */ + readonly daysInMonthOptions = [ + { id: 'actual', label: 'Actual', fineractId: 1 }, + { id: '30', label: '30', fineractId: 30 } + ]; + + readonly calcPeriodOptions = [ + { id: 'same', label: 'Same as repayment period' }, + { id: 'daily', label: 'Daily' } + ]; + + readonly interestStartOptions = [ + { id: 'disbursement', label: 'Disbursement date' }, + { id: 'first_emi', label: 'First EMI date' } + ]; + + readonly compoundingOptions = [ + { id: 'none', label: 'None (simple on outstanding)' }, + { id: 'monthly', label: 'Monthly' } + ]; + + readonly benchmarkOptions = [ + { id: 'repo', label: 'Repo / MCLR / Bank base rate' }, + { id: 'eblr', label: 'External benchmark (EBLR)' } + ]; + + readonly resetFrequencyOptions = [ + { id: 'quarterly', label: 'Quarterly' }, + { id: 'half_yearly', label: 'Half-yearly' }, + { id: 'yearly', label: 'Yearly' } + ]; + + readonly resetChangeOptions = [ + { id: 'tenure', label: 'Tenure (EMI stays constant)' }, + { id: 'emi', label: 'EMI (tenure stays constant)' } + ]; + + constructor() { + this.loanProductLoanInterestForm = this.formBuilder.group({ + interestType: ['fixed'], + // The rate comes from the Loan Interest Rate Register, so a product with no register row cannot be created. + registerParticular: [ + '', + Validators.required + ], + applyCibilAdjustment: ['YES'], + spread: [ + 0, + [ + Validators.min(0), + Validators.max(20) + ] + ], + rateResolvedAt: ['sanction'], + // Filled from the register row; never typed in. + minRate: [''], + defaultRate: [''], + maxRate: [''], + calcMethod: ['declining'], + daysInYear: ['actual'], + daysInMonth: ['actual'], + calcPeriod: ['same'], + interestStartsFrom: ['disbursement'], + compounding: ['none'], + benchmark: ['repo'], + resetFrequency: ['quarterly'], + resetChange: ['tenure'] + }); + } + + /** + * The register row decides the rate limits. With no row for the product the register particular stays empty, + * which keeps this step, and so the whole product, invalid until the product has been added to the register. + */ + onRegisterSelection(selection: RegisterRateSelection | null): void { + this.registerSelection = selection; + const form = this.loanProductLoanInterestForm; + if (selection) { + form.patchValue( + { + registerParticular: selection.particular, + minRate: selection.minRate ?? '', + maxRate: selection.maxRate ?? '', + defaultRate: selection.defaultRate ?? '' + }, + { emitEvent: false } + ); + } else { + form.patchValue({ registerParticular: '', minRate: '', maxRate: '', defaultRate: '' }, { emitEvent: false }); + } + form.updateValueAndValidity({ emitEvent: false }); + } + + /** Reset options only apply to a floating or hybrid rate. */ + get isFloatingOrHybrid(): boolean { + const type = this.loanProductLoanInterestForm.get('interestType').value; + return type === 'floating' || type === 'hybrid'; + } + + get loanProductLoanInterest() { + return this.loanProductLoanInterestForm.getRawValue(); + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.html new file mode 100644 index 000000000..b17ca654c --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.html @@ -0,0 +1,144 @@ + + +
+
+
+ +

Part-prepayment

+
+
+ Part-prepayment allowed +
+
+ + Minimum Part-payment (₹) + + + + Allowed Only On + + @for (opt of allowedOnOptions; track opt.id) { + {{ opt.label }} + } + + + + Part-payments per Year + + @for (opt of perYearOptions; track opt.id) { + {{ opt.label }} + } + + +
+ + + Reduce tenure + EMI stays the same + + + Reduce EMI + Tenure stays the same + + + Borrower chooses + Picked at payment time + + +
+
+ +
+
+ +

Pre-closure (Foreclosure)

+ Charge on outstanding principal +
+
+ + Individual · floating rate (%) + + Nil as per RBI + + + Individual · fixed rate (%) + + + + Non-individual / business (%) + + + + Takeover by another bank (%) + + +
+
+ + Lock-in Period (months) + + + + Interest on Pre-closure + + @for (opt of interestOnPreclosureOptions; track opt.id) { + {{ opt.label }} + } + + + + Foreclosure Quote Valid (days) + + +
+
+ +
+
+ +

Closure & Restructuring

+
+
+ + Auto-close on Zero Balance + + @for (opt of autoCloseOptions; track opt) { + {{ opt }} + } + + + + NOC / Closure Letter + + @for (opt of nocOptions; track opt.id) { + {{ opt.label }} + } + + + + Restructuring / Re-schedule + + @for (opt of restructuringOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+
+ Release collateral and lien on closure + Top-up loan on the same account + Allow balance transfer (takeover) out +
+
+
+
Unlike an Advance, there is no renewal. A loan ends at maturity or on pre-closure.
+
+
+ diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.ts new file mode 100644 index 000000000..e5c6487e8 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.ts @@ -0,0 +1,117 @@ +/** + * 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 { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatRadioButton, MatRadioGroup } from '@angular/material/radio'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Prepayment & Closure step for a Loans A/C (EMI) product (step 8). A loan has no renewal; it ends at + * maturity or on pre-closure. UI-only for now: not sent in the create payload. + */ +@Component({ + selector: 'mifosx-loan-product-loan-prepayment-step', + templateUrl: './loan-product-loan-prepayment-step.component.html', + styleUrls: ['../advance-steps.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatCheckbox, + MatRadioGroup, + MatRadioButton + ] +}) +export class LoanProductLoanPrepaymentStepComponent { + private formBuilder = inject(UntypedFormBuilder); + + loanProductLoanPrepaymentForm: UntypedFormGroup; + + readonly allowedOnOptions = [ + { id: 'emi_date', label: 'EMI due date' }, + { id: 'any_day', label: 'Any day' } + ]; + + readonly perYearOptions = [ + { id: 'unlimited', label: 'Unlimited' }, + { id: 'one', label: 'Once a year' }, + { id: 'two', label: 'Twice a year' } + ]; + + readonly interestOnPreclosureOptions = [ + { id: 'till_date', label: 'Till date of payment' }, + { id: 'full_month', label: 'Full current month' } + ]; + + readonly autoCloseOptions = [ + 'YES', + 'NO' + ]; + + readonly nocOptions = [ + { id: 'auto', label: 'Auto-generate' }, + { id: 'manual', label: 'On request' } + ]; + + readonly restructuringOptions = [ + { id: 'approval', label: 'Allowed with approval' }, + { id: 'no', label: 'Not allowed' } + ]; + + constructor() { + const percent = [ + Validators.min(0), + Validators.max(100) + ]; + this.loanProductLoanPrepaymentForm = this.formBuilder.group({ + partPrepaymentAllowed: [true], + minPartPayment: [ + '', + Validators.min(0) + ], + allowedOn: ['emi_date'], + partPaymentsPerYear: ['unlimited'], + partPrepaymentMode: ['reduce_tenure'], + foreclosureFloatingIndividual: [ + 0, + percent + ], + foreclosureFixedIndividual: [ + 2, + percent + ], + foreclosureNonIndividual: [ + 3, + percent + ], + foreclosureTakeover: [ + 2, + percent + ], + lockInMonths: [ + 0, + Validators.min(0) + ], + interestOnPreclosure: ['till_date'], + foreclosureQuoteValidDays: [ + 7, + Validators.min(1) + ], + autoCloseOnZeroBalance: ['YES'], + nocLetter: ['auto'], + restructuring: ['approval'], + releaseCollateralOnClosure: [true], + topUpAllowed: [false], + balanceTransferOutAllowed: [true] + }); + } + + get loanProductLoanPrepayment() { + return this.loanProductLoanPrepaymentForm.getRawValue(); + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.html new file mode 100644 index 000000000..4972737f7 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.html @@ -0,0 +1,155 @@ + + +
+
+
+ +

Repayment Type

+
+ + @for (opt of repaymentTypeOptions; track opt.id) { + + {{ opt.label }} + {{ opt.hint }} + + } + +
+
+ This is the main difference from an Advance. Repayment follows a fixed schedule, not a running balance. +
+
+
+ +
+
+ +

Schedule

+
+
+ + Repaid Every + + Repaid Every is required + + + Frequency + + @for (opt of frequencyOptions; track opt.id) { + {{ opt.label }} + } + + + + Minimum Days to First Repayment + + + + EMI Due Day + + @for (opt of emiDueDayOptions; track opt.id) { + {{ opt.label }} + } + + + + If Due Date is a Holiday + + @for (opt of holidayRuleOptions; track opt.id) { + {{ opt.label }} + } + + + + Moratorium / Grace (months) + + + + Interest during Moratorium + + @for (opt of moratoriumInterestOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ Variable installments allowed +
+
+ +
+
+ +

Repayment Mode

+
+
+ + Repayment Mode + + @for (opt of repaymentModeOptions; track opt.id) { + {{ opt.label }} + } + + + + Linked Account Type + + @for (opt of linkedAccountOptions; track opt.id) { + {{ opt.label }} + } + + + + PDC Collection + + @for (opt of pdcOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ + Mandate registration is required before the first disbursement + +
+
+
+ Cheques are used here to repay the EMI as post-dated cheques, not to withdraw from the loan. A cheque book is + therefore not part of a Loan A/C. +
+
+
+ +
+
+ +

Appropriation Order

+
+
+ + Appropriation Order + + @for (opt of appropriationOrderOptions; track opt.id) { + {{ opt.label }} + } + + + + Advance EMI Payment + + @for (opt of advanceEmiOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.ts new file mode 100644 index 000000000..162f2cd8d --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.ts @@ -0,0 +1,129 @@ +/** + * 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 { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatRadioButton, MatRadioGroup } from '@angular/material/radio'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Repayment & EMI step for a Loans A/C (EMI) product (step 5) — the step an Advance does not have. + * Repayment type and frequency feed Fineract's core product fields; the repayment mode and the + * rest of the schedule options are UI-only for now. + */ +@Component({ + selector: 'mifosx-loan-product-loan-repayment-step', + templateUrl: './loan-product-loan-repayment-step.component.html', + styleUrls: ['../advance-steps.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatCheckbox, + MatRadioGroup, + MatRadioButton + ] +}) +export class LoanProductLoanRepaymentStepComponent { + private formBuilder = inject(UntypedFormBuilder); + + loanProductLoanRepaymentForm: UntypedFormGroup; + + readonly repaymentTypeOptions = [ + { id: 'emi', label: 'EMI', hint: 'Reducing balance, equal instalments' }, + { id: 'equal_principal', label: 'Equal principal', hint: 'Falling instalments' }, + { id: 'flat', label: 'Flat rate', hint: 'Interest on original amount' }, + { id: 'bullet', label: 'Bullet', hint: 'Pay everything at maturity' }, + { id: 'step', label: 'Step-up / Balloon', hint: 'Custom schedule' } + ]; + + /** `fineractId` is Fineract's repaymentFrequencyType (0 days, 1 weeks, 2 months). */ + readonly frequencyOptions = [ + { id: 'months', label: 'Months', fineractId: 2 }, + { id: 'weeks', label: 'Weeks', fineractId: 1 }, + { id: 'days', label: 'Days', fineractId: 0 } + ]; + + readonly emiDueDayOptions = [ + { id: 'disbursement_day', label: 'Same day as disbursement' }, + { id: 'fixed_day', label: 'Fixed day of month (5th)' } + ]; + + readonly holidayRuleOptions = [ + { id: 'next', label: 'Next working day' }, + { id: 'previous', label: 'Previous working day' }, + { id: 'same', label: 'Same day' } + ]; + + readonly moratoriumInterestOptions = [ + { id: 'pre_emi', label: 'Pay monthly (Pre-EMI)' }, + { id: 'capitalise', label: 'Capitalise into loan' } + ]; + + readonly repaymentModeOptions = [ + { id: 'nach', label: 'Auto-debit (NACH / ECS)' }, + { id: 'pdc', label: 'Post-dated cheques (PDC)' }, + { id: 'cash', label: 'Cash / transfer at branch' } + ]; + + readonly linkedAccountOptions = [ + { id: 'savings_current', label: 'Savings / Current' }, + { id: 'savings', label: 'Savings only' } + ]; + + readonly pdcOptions = [ + { id: 'mandatory', label: 'Mandatory — 3 cheques' }, + { id: 'optional', label: 'Optional' }, + { id: 'none', label: 'Not collected' } + ]; + + readonly appropriationOrderOptions = [ + { id: 'charges_penal_interest_principal', label: 'Charges → Penal → Interest → Principal' }, + { id: 'principal_interest_penal_charges', label: 'Principal → Interest → Penal → Charges' } + ]; + + readonly advanceEmiOptions = [ + { id: 'adjust_next', label: 'Allowed — adjust next EMI' }, + { id: 'not_allowed', label: 'Not allowed' } + ]; + + constructor() { + this.loanProductLoanRepaymentForm = this.formBuilder.group({ + repaymentType: ['emi'], + repaidEvery: [ + 1, + [ + Validators.required, + Validators.min(1) + ] + ], + frequency: ['months'], + minDaysToFirstRepayment: [ + '', + Validators.min(0) + ], + emiDueDay: ['fixed_day'], + holidayRule: ['next'], + moratoriumMonths: [ + 0, + Validators.min(0) + ], + moratoriumInterest: ['pre_emi'], + variableInstallments: [false], + repaymentMode: ['nach'], + linkedAccountType: ['savings_current'], + pdcCollection: ['mandatory'], + mandateRequired: [true], + appropriationOrder: ['charges_penal_interest_principal'], + advanceEmi: ['adjust_next'] + }); + } + + get loanProductLoanRepayment() { + return this.loanProductLoanRepaymentForm.getRawValue(); + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.html new file mode 100644 index 000000000..6650b8f5a --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.html @@ -0,0 +1,130 @@ + + +
+
+
+ +

Security

+
+
+ + Security Type + + + + Security Mandatory + + @for (opt of securityMandatoryOptions; track opt.id) { + {{ opt.label }} + } + + + + Margin (%) + + Margin must be between 0 and 100 + + + Maximum LTV (%) + + = 100 − Margin + +
+
+ +
+
+ +

Collateral Rules by Security Type

+ Which security this product may accept +
+
+ + + + + + + + + + + + @for (rule of collateralRules; track rule.security) { + + + + + + + + + } + +
SecurityAllowedMax LTV %ValuationInsuranceCharge / lien
{{ rule.security }} + {{ rule.allowed ? 'Yes' : 'No' }} + {{ rule.ltv }}{{ rule.valuation }}{{ rule.insurance }}{{ rule.charge }}
+
+ +
+
+ +

Guarantor & Co-applicant

+
+
+ + Guarantors Required (min) + + + + Co-applicant + + @for (opt of coApplicantOptions; track opt.id) { + {{ opt.label }} + } + + + + Third-party Security + + @for (opt of thirdPartyOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ +
+
+ +

Conditions

+
+
+
+ + Legal opinion / title search mandatory before sanction + + + Property insurance mandatory for the whole tenure + + Register charge (CERSAI) before disbursement + Release security automatically on closure + Allow multiple securities for one loan + Allow third-party deposit as security +
+
+
+
+ Unlike an Advance, the security does not decide a running limit. It only decides eligibility at sanction. + Disbursement happens once, up to the sanctioned amount. +
+
+
+ diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.ts new file mode 100644 index 000000000..39caf70e0 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.ts @@ -0,0 +1,127 @@ +/** + * 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 { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Security & Collateral step for a Loans A/C (EMI) product (step 3). + * + * Unlike an Advance, the security only decides eligibility at sanction — it does not set a running + * limit. UI-only for now: not sent in the create payload. + */ +@Component({ + selector: 'mifosx-loan-product-loan-security-step', + templateUrl: './loan-product-loan-security-step.component.html', + styleUrls: ['../advance-steps.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatCheckbox + ] +}) +export class LoanProductLoanSecurityStepComponent { + private formBuilder = inject(UntypedFormBuilder); + + loanProductLoanSecurityForm: UntypedFormGroup; + + readonly securityMandatoryOptions = [ + { id: 'yes', label: 'Yes — no sanction without security' }, + { id: 'no', label: 'No — unsecured' } + ]; + + readonly coApplicantOptions = [ + { id: 'joint_property', label: 'Mandatory for joint property' }, + { id: 'always', label: 'Always mandatory' }, + { id: 'optional', label: 'Optional' } + ]; + + readonly thirdPartyOptions = [ + { id: 'consent', label: 'Allowed with owner consent' }, + { id: 'no', label: 'Not allowed' } + ]; + + /** Reference table: which security a loan on this product may accept. */ + readonly collateralRules = [ + { + security: 'Residential property', + allowed: true, + ltv: '80', + valuation: 'Empanelled valuer', + insurance: 'Mandatory', + charge: 'Mortgage + CERSAI' + }, + { + security: 'Commercial property', + allowed: true, + ltv: '70', + valuation: 'Empanelled valuer', + insurance: 'Mandatory', + charge: 'Mortgage + CERSAI' + }, + { + security: 'Fixed / Recurring Deposit', + allowed: true, + ltv: '90', + valuation: 'Not needed', + insurance: 'Not needed', + charge: 'Lien marked' + }, + { + security: 'LIC policy', + allowed: true, + ltv: '90', + valuation: 'Surrender value', + insurance: 'Not needed', + charge: 'Assignment' + }, + { security: 'Vehicle', allowed: false, ltv: '—', valuation: '—', insurance: '—', charge: '—' }, + { security: 'Gold ornaments', allowed: false, ltv: '—', valuation: '—', insurance: '—', charge: '—' } + ]; + + constructor() { + this.loanProductLoanSecurityForm = this.formBuilder.group({ + securityType: ['Immovable property (mortgage)'], + securityMandatory: ['yes'], + margin: [ + 20, + [ + Validators.required, + Validators.min(0), + Validators.max(100) + ] + ], + guarantorsRequired: [ + 1, + Validators.min(0) + ], + coApplicant: ['joint_property'], + thirdPartySecurity: ['consent'], + legalOpinionRequired: [true], + propertyInsuranceRequired: [true], + registerCharge: [true], + autoReleaseOnClosure: [true], + allowMultipleSecurities: [false], + allowThirdPartyDeposit: [false] + }); + } + + /** Maximum LTV is derived from the margin (100 − margin) and shown read-only. */ + get ltv(): number { + const margin = Number(this.loanProductLoanSecurityForm.get('margin').value); + return Number.isFinite(margin) ? Math.max(0, 100 - margin) : 100; + } + + get loanProductLoanSecurity() { + return { + ...this.loanProductLoanSecurityForm.getRawValue(), + ltv: this.ltv + }; + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.html b/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.html new file mode 100644 index 000000000..377661f29 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.html @@ -0,0 +1,97 @@ + + +@switch (state) { + @case ('loading') { +
Reading the Loan Interest Rate Register…
+ } + @case ('none') { +
+ There is no active Loan Interest Rate Register yet. + + A product takes its interest rate from the register, so it cannot be created until a register is created and + approved. + + Open the register → +
+ } + @case ('ready') { + @if (selectedRow) { +
+ + Interest comes from the active register {{ register?.name }}, effective {{ register?.effectiveFrom }}. + + Open register → +
+ } + + @if (!selectedRow) { +
+ This product has no interest rate in the Loan Interest Rate Register yet. + {{ blockReason }} + Add it in the register → +
+ } + + @if (selectedRow) { +
+ Register row + {{ selectedRow.srNo }}. {{ selectedRow.particulars }} + @if (glLabel(selectedRow); as gl) { + {{ gl }} + } + {{ matchNote }} +
+ +
+ + + + + + + + + @for (slab of slabRates; track slab.label) { + + + + + } + +
Amount slabRate
{{ slab.label }}{{ slab.text }}
+ + + + + + + + + + + @for (slab of cibilSlabs; track slab.from) { + + + + + + } + +
CIBIL scoreBandAdded to the rate
{{ slab.from }} – {{ slab.to }}{{ slab.tier }}{{ slab.adjustmentPct > 0 ? '+' + slab.adjustmentPct.toFixed(2) + '%' : 'No change' }}
+
+ @if (selectedRow.isFormula || slabRates[0]?.formula) { +
+ This row is the deposit's own rate plus a spread, so the final rate is worked out on each account from its + deposit. +
+ } + } + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.scss b/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.scss new file mode 100644 index 000000000..4347cf852 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.scss @@ -0,0 +1,106 @@ +/** + * 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/. + */ + +:host { + display: block; +} + +.rrv__source { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + + a { + font-weight: 600; + white-space: nowrap; + } +} + +.rrv__block { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 16px; + margin: 16px 24px 0; + padding: 12px 16px; + border: 1px solid #fecaca; + border-radius: 8px; + background: #fef2f2; + color: #991b1b; + font-size: 13px; + line-height: 1.5; + + strong { + flex: 1 1 100%; + font-size: 14px; + } + + span { + flex: 1 1 360px; + } +} + +.rrv__block-btn { + font-weight: 600; + color: #991b1b; + white-space: nowrap; +} + +.rrv__row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 14px; + padding: 14px 24px 4px; + font-size: 14px; + color: #1e293b; +} + +.rrv__row-label { + font-size: 12px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #5f6368; +} + +.rrv__row-gl { + padding: 1px 10px; + border-radius: 999px; + background: #e0f2fe; + color: #075985; + font-size: 12px; + font-weight: 600; +} + +.rrv__row-note { + font-size: 12px; + color: #5f6368; +} + +.rrv__tables { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + padding: 8px 24px 16px; + + @media (width <= 900px) { + grid-template-columns: 1fr; + } +} + +.rrv__formula { + color: #075985; + font-weight: 600; +} + +.rrv__note { + padding: 0 24px 16px; + font-size: 13px; + color: #5f6368; +} diff --git a/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.ts b/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.ts new file mode 100644 index 000000000..44d9a35cc --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.ts @@ -0,0 +1,220 @@ +/** + * 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, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges, inject } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { RateRegisterService } from 'app/products/loan-interest-rate-register/rate-register.service'; +import { + CibilSlab, + RateCell, + RateChartRow, + RateRegister +} from 'app/products/loan-interest-rate-register/models/rate-register.model'; + +/** How the register row was found for the product. */ +export type RegisterMatch = 'gl' | 'type'; + +/** What the product takes from the register row, handed to the step that owns the rate fields. */ +export interface RegisterRateSelection { + particular: string; + srNo: number; + glAccountId: number | null; + /** True when the row holds formulas such as "1% + FD": the rate is the deposit's own rate plus a spread. */ + isFormula: boolean; + /** Lowest and highest plain rate in the row, the top one including the largest CIBIL adjustment. Null for a formula row. */ + minRate: number | null; + maxRate: number | null; + /** The rate the product is created with: the lowest plain rate, or the lowest spread for a formula row. */ + defaultRate: number | null; + /** Lowest and highest spread over the deposit rate; only for a formula row. */ + minSpread: number | null; + maxSpread: number | null; + matchedBy: RegisterMatch; + registerName: string; +} + +/** + * Product sub type id -> a word of the register particular that goes with it. This only covers the register's + * default rows. Any other product is found through its GL account, which every register row carries. + */ +const PARTICULAR_BY_SUB_TYPE: Record = { + fd: 'against fdr', + rd: 'against rd', + gold: 'gold loan', + cc: 'cash credit', + overdraft: 'overdraft', + kcc: 'agriculture', + housing: 'housing loan', + personal: 'personal loan (unsecured)', + vehicle: 'car loan (new)', + business: 'business term loan', + term: 'business term loan', + gold_emi: 'gold loan' +}; + +/** + * Shows the interest of a product as the Loan Interest Rate Register defines it: the rate of the product's own + * register row for each amount slab, and the CIBIL adjustment. Nothing is chosen or typed here. + * + * The row is found from the product's GL account first (the register maps each row to a GL), then from its sub + * type. The row found is reported through `selectionChange`, or null when there is none, which blocks the product. + */ +@Component({ + selector: 'mifosx-register-rate-view', + templateUrl: './register-rate-view.component.html', + styleUrls: [ + '../advance-steps.scss', + './register-rate-view.component.scss' + ], + imports: [RouterLink] +}) +export class RegisterRateViewComponent implements OnInit, OnChanges { + private registerService = inject(RateRegisterService); + + /** Sub type id picked on the Details step, e.g. `fd`. */ + @Input() productSubType = ''; + /** The product's GL account id from the Details step. */ + @Input() glAccountId: number | string | null = null; + @Output() selectionChange = new EventEmitter(); + + state: 'loading' | 'ready' | 'none' = 'loading'; + register?: RateRegister; + selectedSrNo: number | null = null; + matchedBy: RegisterMatch | null = null; + /** More than one register row fits the product and nothing tells them apart. */ + ambiguous = false; + + ngOnInit(): void { + this.registerService.getGlOptions().subscribe(); + this.registerService.list().subscribe(() => { + this.register = this.registerService.getActive(); + this.state = this.register && this.register.rows.length > 0 ? 'ready' : 'none'; + this.autoSelect(); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + // The Details step can change after this one has opened, and a new type or GL means a new row. + if ((changes['productSubType'] || changes['glAccountId']) && this.state === 'ready') { + this.autoSelect(); + } + } + + get rows(): RateChartRow[] { + return this.register?.rows ?? []; + } + + get selectedRow(): RateChartRow | undefined { + return this.rows.find((row) => row.srNo === this.selectedSrNo); + } + + get cibilSlabs(): CibilSlab[] { + return this.register?.cibilSlabs ?? []; + } + + glLabel(row: RateChartRow): string | null { + return this.registerService.glLabel(row.glAccountId); + } + + /** The row's rate for every amount slab, ready to show. */ + get slabRates(): { label: string; text: string; formula: boolean; na: boolean }[] { + const row = this.selectedRow; + if (!row || !this.register) { + return []; + } + return this.register.slabs.map((slab, index) => { + const cell: RateCell = row.rates[index] ?? null; + return { + label: slab.label, + text: cell === null ? 'N.A.' : typeof cell === 'string' ? cell : `${cell.toFixed(2)}%`, + formula: typeof cell === 'string', + na: cell === null + }; + }); + } + + /** Why the product cannot go on: what the officer has to do in the register first. */ + get blockReason(): string { + const hasGl = this.glAccountId !== null && this.glAccountId !== ''; + if (!this.productSubType && !hasGl) { + return 'Choose the product type and the GL account on the Details step first.'; + } + const gl = hasGl ? this.registerService.glLabel(Number(this.glAccountId)) : null; + if (this.ambiguous) { + return `More than one register row uses the GL account${gl ? ` (${gl})` : ''}. Give each row its own GL account in the register so this product can be matched to one of them.`; + } + const forGl = gl ? ` with its GL account (${gl})` : ' with its GL account'; + return `Add this product${forGl} and its interest rates in the register and get the register approved. The product can be created only after that.`; + } + + /** How the row was found, shown beside it. */ + get matchNote(): string { + return this.matchedBy === 'gl' + ? `Found through the product's GL account (${this.glLabel(this.selectedRow!) ?? 'GL'}).` + : 'Found through the product type.'; + } + + private autoSelect(): void { + const rows = this.rows; + const word = PARTICULAR_BY_SUB_TYPE[this.productSubType]?.toLowerCase(); + const byType = word ? rows.filter((row) => row.particulars.toLowerCase().includes(word)) : []; + const gl = this.glAccountId === null || this.glAccountId === '' ? null : Number(this.glAccountId); + const byGl = gl === null ? [] : rows.filter((row) => row.glAccountId === gl); + + let chosen: RateChartRow | undefined; + let by: RegisterMatch | null = null; + this.ambiguous = false; + if (byGl.length === 1) { + chosen = byGl[0]; + by = 'gl'; + } else if (byGl.length > 1) { + // Several rows share the GL: the product type tells them apart, or nothing does. + chosen = byGl.find((row) => byType.includes(row)); + by = chosen ? 'type' : null; + this.ambiguous = !chosen; + } else if (byType.length === 1) { + chosen = byType[0]; + by = 'type'; + } else if (byType.length > 1) { + this.ambiguous = true; + } + this.selectedSrNo = chosen?.srNo ?? null; + this.matchedBy = by; + this.emit(); + } + + private emit(): void { + const row = this.selectedRow; + if (!row || !this.register) { + this.selectionChange.emit(null); + return; + } + const plain = row.rates.filter((cell): cell is number => typeof cell === 'number'); + const spreads = row.rates + .filter((cell): cell is string => typeof cell === 'string') + .map((text) => /(\d+(?:\.\d+)?)\s*%/.exec(text)?.[1]) + .filter((value): value is string => !!value) + .map(Number); + const topAdjustment = Math.max(0, ...this.cibilSlabs.map((slab) => Number(slab.adjustmentPct) || 0)); + const isFormula = spreads.length > 0 && plain.length === 0; + const minRate = plain.length > 0 ? Math.min(...plain) : null; + this.selectionChange.emit({ + particular: row.particulars, + srNo: row.srNo, + glAccountId: row.glAccountId ?? null, + isFormula, + minRate, + maxRate: plain.length > 0 ? Math.round((Math.max(...plain) + topAdjustment) * 100) / 100 : null, + defaultRate: isFormula ? Math.min(...spreads) : minRate, + minSpread: isFormula ? Math.min(...spreads) : null, + maxSpread: isFormula ? Math.max(...spreads) : null, + matchedBy: this.matchedBy ?? 'type', + registerName: this.register.name + }); + } +} diff --git a/src/app/products/loan-products/loan-product-types.ts b/src/app/products/loan-products/loan-product-types.ts new file mode 100644 index 000000000..f310cef62 --- /dev/null +++ b/src/app/products/loan-products/loan-product-types.ts @@ -0,0 +1,64 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * The category ("product type") and sub type of a loan product. One list shared by the create-product wizard, the + * product list filters and the loan account product picker, so a type added here appears in all three. + */ + +export interface ProductTypeGroup { + id: string; + label: string; + hint: string; +} + +export interface ProductSubType { + id: string; + label: string; + /** The two-letter code the product's Short Name is pre-filled with when the sub type is picked. */ + shortName: string; +} + +export const PRODUCT_TYPE_GROUPS: ProductTypeGroup[] = [ + { id: 'advances', label: 'Advances A/C', hint: 'No EMI. Limit / drawing power against security.' }, + { id: 'loans', label: 'Loans A/C', hint: 'Fixed disbursement, fixed tenure, EMI repayment.' } +]; + +export const PRODUCT_SUB_TYPES: Record = { + advances: [ + { id: 'fd', label: 'Loan against FD', shortName: 'FD' }, + { id: 'rd', label: 'Loan against RD', shortName: 'RD' }, + { id: 'dds', label: 'Loan against DDS', shortName: 'DS' }, + { id: 'lic', label: 'Loan against LIC Policy', shortName: 'LI' }, + { id: 'gold', label: 'Loan against Gold', shortName: 'GD' }, + { id: 'warehouse', label: 'Warehouse Receipt', shortName: 'WR' }, + { id: 'cc', label: 'Cash Credit (CC)', shortName: 'CC' }, + { id: 'overdraft', label: 'Overdraft (OD)', shortName: 'OD' }, + { id: 'kcc', label: 'Kisan Credit Card (KCC)', shortName: 'KC' }, + { id: 'bills', label: 'Bills Purchased / Discounted', shortName: 'BP' } + ], + loans: [ + { id: 'term', label: 'Term Loan', shortName: 'TL' }, + { id: 'personal', label: 'Personal Loan', shortName: 'PL' }, + { id: 'housing', label: 'Housing Loan', shortName: 'HL' }, + { id: 'vehicle', label: 'Vehicle Loan', shortName: 'VL' }, + { id: 'education', label: 'Education Loan', shortName: 'EL' }, + { id: 'business', label: 'Business / MSME Loan', shortName: 'BL' }, + { id: 'gold_emi', label: 'Gold EMI Loan', shortName: 'GE' } + ] +}; + +/** Label of a category id, or a dash for a product that carries none (created before categories existed). */ +export function productCategoryLabel(group: string | null | undefined): string { + return PRODUCT_TYPE_GROUPS.find((g) => g.id === group)?.label ?? '—'; +} + +/** Label of a sub type id within its category, or a dash when unknown. */ +export function productTypeLabel(group: string | null | undefined, subGroup: string | null | undefined): string { + return (PRODUCT_SUB_TYPES[group ?? ''] ?? []).find((s) => s.id === subGroup)?.label ?? '—'; +} diff --git a/src/app/products/loan-products/loan-products.component.scss b/src/app/products/loan-products/loan-products.component.scss index e15399058..48f1f175d 100644 --- a/src/app/products/loan-products/loan-products.component.scss +++ b/src/app/products/loan-products/loan-products.component.scss @@ -15,3 +15,49 @@ --pl-accent: #b45309; --pl-accent-l: #fef3c7; } + +/* Category / product type / active-only filters beside the search box. */ +.pl-filters { + display: flex; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; +} + +.pl-filter { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 11px; + color: #64748b; + + select { + height: 34px; + min-width: 160px; + padding: 0 8px; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #fff; + font-size: 13px; + color: #1e293b; + } + + &--check { + flex-direction: row; + align-items: center; + gap: 6px; + height: 34px; + font-size: 13px; + color: #1e293b; + } +} + +.pl-tag--advances { + background: #e0f2fe; + color: #075985; +} + +.pl-tag--loans { + background: #ede9fe; + color: #5b21b6; +} diff --git a/src/app/products/loan-products/loan-products.component.ts b/src/app/products/loan-products/loan-products.component.ts index f16a3bff1..f316a73ab 100644 --- a/src/app/products/loan-products/loan-products.component.ts +++ b/src/app/products/loan-products/loan-products.component.ts @@ -42,6 +42,7 @@ import { MatTooltip } from '@angular/material/tooltip'; import { StatusLookupPipe } from '../../pipes/status-lookup.pipe'; import { DateFormatPipe } from '../../pipes/date-format.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS, productCategoryLabel, productTypeLabel } from './loan-product-types'; @Component({ selector: 'mifosx-loan-products', @@ -82,6 +83,8 @@ export class LoanProductsComponent implements OnInit, AfterViewInit { displayedColumns: string[] = [ 'name', 'shortName', + 'category', + 'productType', 'closeDate', 'status', 'actions' @@ -90,6 +93,31 @@ export class LoanProductsComponent implements OnInit, AfterViewInit { filteredCount = 0; + /** Category and type filters, fed by the classification each product carries. */ + readonly categories = PRODUCT_TYPE_GROUPS; + categoryFilter = ''; + typeFilter = ''; + activeOnly = false; + textFilter = ''; + + /** 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(); + } + + categoryOf(product: any): string { + return productCategoryLabel(product?.advanceExtensions?.productTypeGroup); + } + + typeOf(product: any): string { + return productTypeLabel( + product?.advanceExtensions?.productTypeGroup, + product?.advanceExtensions?.productTypeSubGroup + ); + } + get totalCount(): number { return this.loanProductsData?.length || 0; } @@ -130,12 +158,52 @@ export class LoanProductsComponent implements OnInit, AfterViewInit { this.dataSource = new MatTableDataSource(this.loanProductsData); this.dataSource.paginator = this.paginator; this.dataSource.sort = this.sort; + this.dataSource.filterPredicate = (product: any) => this.matchesFilters(product); this.filteredCount = this.totalCount; this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length)); } applyFilter(filterValue: string) { - this.dataSource.filter = (filterValue || '').trim().toLowerCase(); + this.textFilter = (filterValue || '').trim().toLowerCase(); + this.refreshFilter(); + } + + 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.refreshFilter(); + } + + /** The filter value itself is unused (the predicate reads the fields above); changing it re-runs the predicate. */ + refreshFilter(): void { + this.dataSource.filter = `${this.categoryFilter}|${this.typeFilter}|${this.activeOnly}|${this.textFilter}`; + } + + private matchesFilters(product: any): boolean { + const ext = product?.advanceExtensions ?? {}; + if (this.categoryFilter && ext.productTypeGroup !== this.categoryFilter) { + return false; + } + if (this.typeFilter && ext.productTypeSubGroup !== this.typeFilter) { + return false; + } + if (this.activeOnly && product?.status !== 'loanProduct.active') { + return false; + } + if (this.textFilter) { + const haystack = [ + product?.name, + product?.shortName, + this.categoryOf(product), + this.typeOf(product) + ] + .join(' ') + .toLowerCase(); + return haystack.includes(this.textFilter); + } + return true; } /** diff --git a/src/app/products/loan-products/loan-products.ts b/src/app/products/loan-products/loan-products.ts index 52e913db3..b097b9b43 100644 --- a/src/app/products/loan-products/loan-products.ts +++ b/src/app/products/loan-products/loan-products.ts @@ -50,10 +50,29 @@ export class LoanProducts { delete loanProduct.advancedAccountingRules; delete loanProduct.loanPeriod; - // The Advances payload is assembled explicitly in the create component and already carries only - // whitelisted params (core Fineract fields + satellite ADVANCE_PARAMETERS), so it is NOT stripped. - // For the Loans path, the Details/Security fields below are not backend params, so drop them. - if (loanProduct.productTypeGroup !== 'advances') { + // The Accounting step carries CBS-specific GL mappings that the backend does not accept yet. They are + // sent as '' when the officer leaves them blank, and the backend rejects the whole product for an + // unsupported parameter, so drop the ones that are empty. A mapping the officer actually selects is still + // sent and gets a clear "not supported" answer rather than being silently ignored. + [ + 'intReceivablesGlAccountId', + 'intPayableGlAccountId', + 'deafIntPayableGlAccountId', + 'intOverdueReserveAccountId', + 'undebitedInterestAccountId', + 'penalIntCrGlAccountId', + 'rdMaturedGlAccountId' + ].forEach((key) => { + if (loanProduct[key] === '' || loanProduct[key] === null || loanProduct[key] === undefined) { + delete loanProduct[key]; + } + }); + + // The Advances and Loans A/C (EMI) payloads are assembled explicitly in the create component and already + // carry only whitelisted params (core Fineract fields + satellite params), so they are NOT stripped. + // For any other product (the old Settings/Terms path), the Details/Security fields below are not backend + // params, so drop them. + if (loanProduct.productTypeGroup !== 'advances' && loanProduct.productTypeGroup !== 'loans') { delete loanProduct.productTypeGroup; delete loanProduct.productTypeSubGroup; delete loanProduct.glAccount; diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 94bc5c969..956c04858 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '261008', - 'hash': 'a5a64d4ef' + 'version': '261010', + 'hash': 'd61584f29' }, 'allow_switching_backend_instance': true };