Compare commits

..
Author SHA1 Message Date
nitesh f0e059c621 feat(deposits): FD Renewal History tab, redesigned Payment action, and renewal-aware Transactions/certificate
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
WHAT THIS CHANGES
==================
Frontend counterpart to the backend FD in-place renewal, senior-citizen
rate, and Payment action work.

1. RENEWAL HISTORY TAB (new) -- fixed-deposit-account-view/renewal-history-tab/.
   A vertical timeline, one card per term of the FD, oldest first. Backend
   data arrives as a list of lowercase-keyed maps (Postgres unquoted SQL
   aliases), which the component normalises. Includes term-rate-chart-dialog,
   a popup showing one term's full rate-by-tenure chart, including the
   senior-citizen column, with an "Applied" badge on the rate that was
   actually used. Route added in fixed-deposits-routing.module.ts, tab
   link added in fixed-deposit-account-view.component.html (shown only
   when the account has renewal history).

2. PAYMENT DIALOG (new) -- fixed-deposit-account-view/custom-dialogs/payment-dialog/.
   Card-based picker for the four supported closure types (Transfer to
   Savings, Re-Invest Principal and Interest, Re-Invest Principal Only,
   Partial Re-Invest), with Withdraw Deposit filtered out client-side.
   Fetches its own template via a new fixedDepositsService method
   (getFixedDepositsAccountPaymentTemplate, command=payment) rather than
   reusing the Close template, since the Payment template is the only one
   the backend includes PARTIAL_REINVEST in.
   The Partial Re-Invest amount cap is date-dependent: the backend bounds
   it against the account balance AFTER posting interest to the chosen
   closed-on date, which lands on the maturity amount on or after
   maturity and on the current balance before it. The dialog mirrors this
   (reinvestCap / processingOnOrAfterMaturity) so the shown split actually
   adds up and the client-side validator agrees with the server. This was
   wrong in an earlier pass of this work (compared against a fixed figure
   regardless of date) and was caught and fixed via a real reconciliation
   check (77000 + 23000 did not equal the displayed total) before this
   commit.
   Added: a loading-failure state (previously the spinner ran forever on
   a failed template fetch), an inline submit-error message (previously a
   failed POST left the dialog open with no feedback), an empty-savings-
   accounts hint, and accessibility attributes on the option cards
   (role="radio", aria-checked, a visible check icon, focus-visible
   outline) since selection was previously colour-only.

3. TRANSACTIONS TAB -- merges synthetic "FD Renewed" marker rows into the
   real transaction list, since an in-place renewal posts no actual
   ledger transaction and would otherwise look like the balance changed
   for no reason.

4. INTEREST RATE CHART TAB -- added the senior-citizen rate column.

5. DEPOSIT CERTIFICATE DIALOG -- lists the full renewal history on FD
   certificates. renewalCount is renewalHistory.length - 1 (the first
   row is the original term, not a renewal); an earlier version of this
   used the raw row count and overstated the renewal count by one on a
   customer-facing document, caught and fixed before this commit.

6. CLIENTS GENERAL TAB -- fixedDepositAccountsForDisplay folds a renewed
   FD's superseded terms out of the account list (each renewal used to
   show as two unrelated accounts, one Closed and one Active). Memoised
   on the source array's identity so it does not rebuild, and rebind the
   table, on every change-detection tick. Only rewrites FD rows; Savings,
   RD, Current, and Share account rows pass through this getter
   untouched, and the RD/Savings sections of the template were left
   bound directly to the original savingAccounts array.

KNOWN CAVEATS
=============
  - This depends on the paired backend commit (same date, "feat(deposits):
    FD in-place renewal, senior-citizen rate persistence, and Payment
    action"). Deploying this frontend without that backend change means
    the Payment dialog and Renewal History tab will call endpoints that
    do not exist yet.
  - Renewal-history rows rely on the backend returning lowercase column
    keys (a Postgres-specific behaviour of unquoted SQL aliases via
    JdbcTemplate). Confirmed correct against this project's Postgres
    dev database; if this project ever runs on a different database
    engine, that assumption would need re-checking.
  - src/environments/.env.ts is intentionally excluded from this commit;
    it is a local build-version stamp file that should never be
    committed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 16:46:22 +05:30
niteshandClaude Opus 5 5c76aa6b27 feat(tasks,deposits): review product edits in a dialog showing only what changed
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
The pending-product-edit comparison listed every field the edit form posted -- around fifty,
nearly all unchanged -- as an inline panel under the table, which buried the handful a checker
actually needs to look at. It is now a dialog listing only fields whose value differs.

Values are resolved to what the maker saw rather than what the payload carries: enum and
GL-account ids become their labels, dates in either API shape read the same, and empty
values no longer register as changes. Without that, option fields compared an expanded
{id, value} object against a bare id and reported every one of them as changed.

Fixed Deposit accounts created by a reinvestment now show the account they came from, and the
matured account shows the one it went into, each linking to the other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 13:00:25 +05:30
58 changed files with 3501 additions and 3216 deletions
-7
View File
@@ -69,10 +69,3 @@ src/environments/.env.ts
/playwright-report/
/blob-report/
/playwright/.cache/
# JVM crash / replay logs
hs_err_pid*.log
replay_pid*.log
# Microsoft Office temporary lock files
~$*.xlsx
@@ -435,12 +435,17 @@
@if (!showClosedFixedAccounts) {
<div class="table-wrapper">
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'open' : 'isFixed'">
<table mat-table [dataSource]="fixedDepositAccountsForDisplay | accountsFilter: 'saving' : 'open' : 'isFixed'">
<ng-container matColumnDef="Account No">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
<td mat-cell *matCellDef="let element">
<i class="fa fa-stop" [ngClass]="element.status.code | statusLookup"></i>
<mifosx-account-number accountNo="{{ element.accountNo }}"></mifosx-account-number>
<mifosx-account-number
accountNo="{{ element.fdAccountNumber || element.accountNo }}"
></mifosx-account-number>
@if (element.renewalCycleNo) {
<span class="fd-renewal-badge">Renewal {{ element.renewalCycleNo }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="Saving Account">
@@ -524,12 +529,20 @@
<!-- Closed Fixed Deposit Accounts -->
@if (showClosedFixedAccounts) {
<div class="table-wrapper">
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'closed' : 'isFixed'">
<table
mat-table
[dataSource]="fixedDepositAccountsForDisplay | accountsFilter: 'saving' : 'closed' : 'isFixed'"
>
<ng-container matColumnDef="Account No">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
<td mat-cell *matCellDef="let element">
<i class="fa fa-stop" [ngClass]="element.status.code | statusLookup"></i>
<mifosx-account-number accountNo="{{ element.accountNo }}"></mifosx-account-number>
<mifosx-account-number
accountNo="{{ element.fdAccountNumber || element.accountNo }}"
></mifosx-account-number>
@if (element.renewalCycleNo) {
<span class="fd-renewal-badge">Renewal {{ element.renewalCycleNo }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="Saving Account">
@@ -209,6 +209,20 @@ $red: #d93025;
gap: 0;
}
// Marks an FD row that is a renewal of an earlier term, since the row shows the original FD number
// rather than this term's own account number.
.fd-renewal-badge {
display: inline-block;
margin-left: 8px;
padding: 1px 7px;
border-radius: 10px;
background-color: $gray-200;
color: $gray-700;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
// ── Table Wrapper ──
.table-wrapper {
border-radius: 0 0 8px 8px;
@@ -234,7 +234,57 @@ export class GeneralTabComponent implements OnDestroy {
}
get fixedDepositCount(): number {
return this.savingAccounts.filter((a: any) => a.depositType?.code === 'depositAccountType.fixedDeposit').length;
return this.fixedDepositAccountsForDisplay.filter(
(a: any) => a.depositType?.code === 'depositAccountType.fixedDeposit'
).length;
}
/**
* Accounts for the Fixed Deposit sections, with superseded renewal cycles folded away.
*
* An FD renewal closes one account and opens the next term as its own account row, so a renewed FD
* would otherwise appear twice on this page -- the matured term under Closed and the new term under
* Active -- reading as two unrelated deposits. A closed term is therefore dropped whenever a later
* cycle of the same FD (same `fdAccountNumber`) exists, leaving one row per FD that shows its
* current term. A chain that has genuinely ended keeps its final term, so it still lists as closed.
*
* Only FD rows are rewritten; savings, RD, current and share accounts pass through untouched.
*/
get fixedDepositAccountsForDisplay(): any[] {
// Memoised on the source array's identity. This getter is bound straight to two [dataSource] inputs, so
// returning a freshly built array on every change-detection pass would make the table re-bind and
// re-render its rows on every tick -- mouse moves included.
if (this.fdDisplayCacheSource !== this.savingAccounts) {
this.fdDisplayCacheSource = this.savingAccounts;
this.fdDisplayCache = this.foldSupersededFdCycles(this.savingAccounts);
}
return this.fdDisplayCache;
}
private fdDisplayCacheSource: any[] = null;
private fdDisplayCache: any[] = [];
private foldSupersededFdCycles(accounts: any[]): any[] {
const latestCycleByFd = new Map<string, number>();
for (const account of accounts) {
if (account?.depositType?.code !== 'depositAccountType.fixedDeposit') {
continue;
}
const fdNumber = account.fdAccountNumber ?? account.accountNo;
const cycle = account.renewalCycleNo ?? 0;
if (cycle > (latestCycleByFd.get(fdNumber) ?? -1)) {
latestCycleByFd.set(fdNumber, cycle);
}
}
return accounts.filter((account: any) => {
if (account?.depositType?.code !== 'depositAccountType.fixedDeposit') {
return true;
}
const fdNumber = account.fdAccountNumber ?? account.accountNo;
const cycle = account.renewalCycleNo ?? 0;
return cycle === latestCycleByFd.get(fdNumber);
});
}
get recurringDepositCount(): number {
@@ -0,0 +1,191 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pmt">
<div class="pmt__header">
<span class="pmt__header-ico"><fa-icon icon="sync-alt"></fa-icon></span>
<div class="pmt__header-text">
<h2 class="pmt__title">{{ 'labels.heading.Payment' | translate }}</h2>
<p class="pmt__subtitle">Process this Fixed Deposit now -- works whether it's Active or Matured.</p>
</div>
</div>
<div mat-dialog-content class="pmt__content">
@if (loadingTemplate) {
<div class="pmt__loading">
<mat-spinner diameter="36"></mat-spinner>
</div>
} @else if (loadFailed) {
<div class="pmt__error" role="alert">
<fa-icon icon="times-circle" class="m-r-10"></fa-icon>
Could not load this account's payment options. Close this dialog and try again.
</div>
} @else {
<form [formGroup]="paymentForm">
@if (reinvestCap !== null && reinvestCap !== undefined) {
<div class="pmt__stat">
<span class="pmt__stat-label">
{{
remainderIsApproximate
? 'Amount available on the selected date'
: ('labels.inputs.Maturity Amount' | translate)
}}
</span>
<span class="pmt__stat-value">{{ reinvestCap | formatNumber }}</span>
</div>
}
<mat-form-field appearance="outline" class="pmt__date" (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On Date' | translate }}</mat-label>
<input
matInput
[max]="maxDate"
[matDatepicker]="closedOnDatePicker"
required
formControlName="closedOnDate"
/>
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closedOnDatePicker></mat-datepicker>
@if (paymentForm.controls.closedOnDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<div class="pmt__section-label" id="pmt-action-label">{{ 'labels.inputs.Action' | translate }}</div>
<div class="pmt__options" role="radiogroup" aria-labelledby="pmt-action-label">
@for (closureOption of onAccountClosureOptions; track closureOption) {
<button
type="button"
class="pmt__option"
role="radio"
[attr.aria-checked]="paymentForm.get('onAccountClosureId').value === closureOption.id"
[class.pmt__option--active]="paymentForm.get('onAccountClosureId').value === closureOption.id"
(click)="selectClosureType(closureOption.id)"
>
<span class="pmt__option-ico"><fa-icon [icon]="iconFor(closureOption.id)"></fa-icon></span>
<span class="pmt__option-text">
<span class="pmt__option-title">{{ closureOption.value }}</span>
<span class="pmt__option-desc">{{ descriptionFor(closureOption.id) }}</span>
</span>
@if (paymentForm.get('onAccountClosureId').value === closureOption.id) {
<fa-icon icon="check-circle" class="pmt__option-check"></fa-icon>
}
</button>
}
</div>
@if (
paymentForm.controls.onAccountClosureId.hasError('required') &&
paymentForm.controls.onAccountClosureId.touched
) {
<div class="pmt__option-error">
{{ 'labels.inputs.Account action' | translate }} {{ 'labels.commons.is' | translate }}
{{ 'labels.commons.required' | translate }}
</div>
}
@if (isPartialReinvest) {
<div class="pmt__split">
<mat-form-field appearance="outline" class="pmt__split-input">
<mat-label>{{ 'labels.inputs.Reinvest Amount' | translate }}</mat-label>
<input type="number" matInput required formControlName="reinvestAmount" />
@if (paymentForm.controls.reinvestAmount?.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Reinvest Amount' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
@if (paymentForm.controls.reinvestAmount?.hasError('min')) {
<mat-error>{{ 'labels.inputs.Reinvest Amount must be greater than zero' | translate }}</mat-error>
}
@if (paymentForm.controls.reinvestAmount?.hasError('belowMaturityAmount')) {
<mat-error>{{
'labels.inputs.Reinvest Amount must be less than the maturity amount' | translate
}}</mat-error>
}
</mat-form-field>
<span class="pmt__split-arrow"><fa-icon icon="arrow-right"></fa-icon></span>
<div class="pmt__split-remainder">
<span class="pmt__split-remainder-label">{{ 'labels.inputs.Remainder to Transfer' | translate }}</span>
<span class="pmt__split-remainder-value">{{
remainderAmount !== null ? (remainderAmount | formatNumber) : '—'
}}</span>
</div>
</div>
@if (remainderIsApproximate) {
<p class="pmt__hint">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
The selected date is before maturity. Interest up to that date is posted first, so the amount actually
transferred will be at least the figure shown.
</p>
}
}
@if (noDestinationAccounts) {
<p class="pmt__hint pmt__hint--warn" role="alert">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
This customer has no active savings account to transfer into. Open one first, or choose an action that keeps
the funds in this deposit.
</p>
}
@if (paymentForm.contains('toSavingsAccountId')) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Transfer to Savings' | translate }}</mat-label>
<mat-select required formControlName="toSavingsAccountId">
@for (account of savingsAccountsData; track account) {
<mat-option [value]="account.id">
{{ account.accountNo }}
</mat-option>
}
</mat-select>
@if (paymentForm.controls.toSavingsAccountId?.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
}
@if (paymentForm.contains('transferDescription')) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Transfer Description' | translate }}</mat-label>
<input matInput formControlName="transferDescription" />
</mat-form-field>
}
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
</mat-form-field>
@if (submitError) {
<p class="pmt__error" role="alert">
<fa-icon icon="times-circle" class="m-r-5"></fa-icon>
{{ submitError }}
</p>
}
</form>
}
</div>
<div mat-dialog-actions align="end" class="pmt__actions">
<button mat-button mat-dialog-close [disabled]="submitting">{{ 'labels.buttons.Cancel' | translate }}</button>
<button
mat-raised-button
color="primary"
[disabled]="!paymentForm?.valid || submitting || loadFailed"
(click)="submit()"
>
{{ submitting ? ('labels.buttons.Processing' | translate) : ('labels.buttons.Confirm' | translate) }}
</button>
</div>
</div>
@@ -0,0 +1,296 @@
/**
* 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/.
*/
$navy: #0d1b2a;
$slate: #475569;
$muted: #64748b;
$border: #e2e8f0;
$soft: #f8fafc;
$blue: #1e40af;
$blue-soft: #eff6ff;
$green: #166534;
$green-soft: #f0fdf4;
.pmt {
display: flex;
flex-direction: column;
min-height: 320px;
max-height: 85vh;
overflow: hidden;
margin: -24px;
}
.pmt__header {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 22px 28px 18px;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 55%, #4338ca 100%);
color: #fff;
flex: 0 0 auto;
}
.pmt__header-ico {
width: 40px;
height: 40px;
min-width: 40px;
border-radius: 10px;
background: rgb(255 255 255 / 15%);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.pmt__title {
margin: 0;
font-size: 19px;
font-weight: 700;
}
.pmt__subtitle {
margin: 4px 0 0;
font-size: 12.5px;
color: rgb(255 255 255 / 82%);
line-height: 1.4;
}
.pmt__content {
padding: 22px 28px 8px !important;
overflow-y: auto;
flex: 1 1 auto;
}
.pmt__loading {
display: flex;
justify-content: center;
align-items: center;
min-height: 260px;
}
.pmt__stat {
display: flex;
flex-direction: column;
gap: 2px;
background: $blue-soft;
border: 1px solid #dbeafe;
border-radius: 10px;
padding: 12px 16px;
margin-bottom: 18px;
}
.pmt__stat-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $blue;
}
.pmt__stat-value {
font-size: 22px;
font-weight: 800;
color: $navy;
font-variant-numeric: tabular-nums;
}
.pmt__date {
width: 100%;
}
.pmt__section-label {
font-size: 11.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $slate;
margin: 4px 0 10px;
}
.pmt__options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 10px;
margin-bottom: 6px;
}
.pmt__option {
display: flex;
align-items: flex-start;
gap: 10px;
text-align: left;
background: #fff;
border: 1.5px solid $border;
border-radius: 10px;
padding: 12px 14px;
cursor: pointer;
font: inherit;
transition:
border-color 0.15s,
background 0.15s,
box-shadow 0.15s;
}
.pmt__option:hover {
border-color: #93c5fd;
background: $blue-soft;
}
.pmt__option--active {
border-color: $blue;
background: $blue-soft;
box-shadow: 0 0 0 1px $blue;
}
.pmt__option-ico {
width: 30px;
height: 30px;
min-width: 30px;
border-radius: 8px;
background: $soft;
color: $blue;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
}
.pmt__option--active .pmt__option-ico {
background: $blue;
color: #fff;
}
.pmt__option-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.pmt__option-title {
font-size: 13px;
font-weight: 700;
color: $navy;
}
.pmt__option-desc {
font-size: 11px;
color: $muted;
line-height: 1.35;
}
.pmt__option:focus-visible {
outline: 2px solid $blue;
outline-offset: 2px;
}
.pmt__option-check {
margin-left: auto;
color: $blue;
font-size: 13px;
}
.pmt__option-error {
color: #b91c1c;
font-size: 12px;
margin: 4px 0 14px;
}
.pmt__error {
display: flex;
align-items: flex-start;
gap: 4px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
color: #b91c1c;
font-size: 12.5px;
line-height: 1.45;
padding: 10px 14px;
margin: 4px 0 8px;
}
.pmt__hint {
display: flex;
align-items: flex-start;
gap: 4px;
background: $soft;
border: 1px solid $border;
border-radius: 8px;
color: $slate;
font-size: 12px;
line-height: 1.45;
padding: 9px 13px;
margin: 0 0 10px;
}
.pmt__hint--warn {
background: #fffbeb;
border-color: #fde68a;
color: #92400e;
}
.pmt__split {
display: flex;
align-items: center;
gap: 12px;
background: $soft;
border: 1px dashed #cbd5e1;
border-radius: 10px;
padding: 14px 16px 0;
margin: 4px 0 8px;
flex-wrap: wrap;
}
.pmt__split-input {
flex: 1 1 180px;
min-width: 160px;
}
.pmt__split-arrow {
color: $muted;
font-size: 14px;
margin-bottom: 20px;
}
.pmt__split-remainder {
display: flex;
flex-direction: column;
gap: 2px;
background: $green-soft;
border: 1px solid #bbf7d0;
border-radius: 8px;
padding: 8px 14px;
margin-bottom: 14px;
flex: 1 1 180px;
min-width: 160px;
}
.pmt__split-remainder-label {
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $green;
}
.pmt__split-remainder-value {
font-size: 16px;
font-weight: 800;
color: $green;
font-variant-numeric: tabular-nums;
}
.pmt__content mat-form-field {
width: 100%;
}
.pmt__actions {
padding: 14px 28px 20px !important;
border-top: 1px solid $border;
flex: 0 0 auto;
}
@@ -0,0 +1,288 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogRef,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { CdkScrollable } from '@angular/cdk/scrolling';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, ValidatorFn, Validators } from '@angular/forms';
/** Custom Services */
import { FixedDepositsService } from '../../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* "Payment" dialog -- an always-available alternative to Premature Close (Active-only, no reinvest) and
* Close (Matured-only, no partial option). Works on an FD in either state and offers a fourth choice,
* Partial Reinvest, alongside the ones the backend's `DepositAccountOnClosureType` already had: the
* entered amount renews on this same account, the rest transfers to a savings account picked here.
*/
@Component({
selector: 'mifosx-payment-dialog',
templateUrl: './payment-dialog.component.html',
styleUrls: ['./payment-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CdkScrollable,
MatDialogContent,
MatDialogActions,
MatDialogClose,
CdkTextareaAutosize,
MatProgressSpinnerModule,
FaIconComponent,
FormatNumberPipe
]
})
export class PaymentDialogComponent implements OnInit {
/** The `onAccountClosureId` codes each closure type is registered under on the backend. */
private static readonly WITHDRAW_DEPOSIT_ID = 100;
private static readonly TRANSFER_TO_SAVINGS_ID = 200;
private static readonly REINVEST_FULL_ID = 300;
private static readonly REINVEST_PRINCIPAL_ID = 400;
private static readonly PARTIAL_REINVEST_ID = 500;
private static readonly ACTION_ICONS: Record<number, string> = {
[PaymentDialogComponent.WITHDRAW_DEPOSIT_ID]: 'money-bill-wave',
[PaymentDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'wallet',
[PaymentDialogComponent.REINVEST_FULL_ID]: 'sync-alt',
[PaymentDialogComponent.REINVEST_PRINCIPAL_ID]: 'piggy-bank',
[PaymentDialogComponent.PARTIAL_REINVEST_ID]: 'arrows-alt-h'
};
private static readonly ACTION_DESCRIPTIONS: Record<number, string> = {
[PaymentDialogComponent.WITHDRAW_DEPOSIT_ID]: 'Pay out the full balance in cash',
[PaymentDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'Move the full balance to another savings account',
[PaymentDialogComponent.REINVEST_FULL_ID]: 'Renew the full amount, same account number',
[PaymentDialogComponent.REINVEST_PRINCIPAL_ID]:
'Renew only the principal; interest transferred to a savings account',
[PaymentDialogComponent.PARTIAL_REINVEST_ID]: 'Renew part of it here; transfer the rest'
};
dialogRef = inject<MatDialogRef<PaymentDialogComponent>>(MatDialogRef);
data: { accountId: number | string; accountBalance?: number; maturityDate?: any } = inject(MAT_DIALOG_DATA);
private formBuilder = inject(UntypedFormBuilder);
private fixedDepositsService = inject(FixedDepositsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
paymentForm: UntypedFormGroup;
/** Loaded once, on open, from the same closure template the existing Close action already uses. */
onAccountClosureOptions: any[] = [];
savingsAccountsData: any[] = [];
maturityAmount: number = null;
loadingTemplate = true;
loadFailed = false;
submitting = false;
submitError: string = null;
maxDate: Date;
ngOnInit(): void {
this.maxDate = this.settingsService.businessDate;
this.paymentForm = this.formBuilder.group({
closedOnDate: [
'',
Validators.required
],
onAccountClosureId: [
'',
Validators.required
],
note: ['']
});
this.paymentForm.get('onAccountClosureId').valueChanges.subscribe((id: number) => this.onClosureTypeChange(id));
// The cap depends on whether the chosen date is on or after maturity, so a date change can make an
// already-entered amount valid or invalid. Without this the amount keeps its verdict from the old date.
this.paymentForm
.get('closedOnDate')
.valueChanges.subscribe(() =>
this.paymentForm.get('reinvestAmount')?.updateValueAndValidity({ emitEvent: false })
);
this.fixedDepositsService.getFixedDepositsAccountPaymentTemplate(this.data.accountId as string).subscribe({
next: (response: any) => {
// Withdraw Deposit books cash out of the vault immediately, which isn't appropriate for a
// staff-triggered Payment that may run well before or after the actual counter transaction --
// Transfer to Savings or a Reinvest option covers every real case instead.
this.onAccountClosureOptions = (response.onAccountClosureOptions ?? []).filter(
(option: any) => option.id !== PaymentDialogComponent.WITHDRAW_DEPOSIT_ID
);
this.savingsAccountsData = response.savingsAccounts ?? [];
this.maturityAmount = response.maturityAmount;
this.loadingTemplate = false;
},
// Without this the spinner would run forever on a failed load, leaving Cancel as the only way out
// and no indication of why the form never appeared.
error: () => {
this.loadingTemplate = false;
this.loadFailed = true;
}
});
}
/**
* The amount being split, and the bound the entered partial amount is checked against.
* <p>
* The server derives this from the account's balance AFTER it posts interest up to the chosen date, so which figure
* matches depends on that date. On or after maturity it posts the remaining full-rate interest, landing on the
* maturity amount. Before maturity it posts penalty-aware interest instead, which lands somewhere between the current
* balance and the maturity amount -- unknowable here, so the current balance is used as a floor that can never accept
* an amount the server would reject, and {@link remainderIsApproximate} tells the user the transfer will be larger.
*/
get reinvestCap(): number | null {
if (this.processingOnOrAfterMaturity) {
return this.maturityAmount ?? this.data.accountBalance ?? null;
}
return this.data.accountBalance ?? this.maturityAmount ?? null;
}
/** Whether the chosen closure date is on or after the deposit's maturity date. */
private get processingOnOrAfterMaturity(): boolean {
const chosen = this.paymentForm?.get('closedOnDate')?.value;
if (!chosen || !this.data.maturityDate) {
return false;
}
return this.toTime(chosen) >= this.toTime(this.data.maturityDate);
}
/** Dates arrive here either as a Date from the picker or as the API's [yyyy, mm, dd] array. */
private toTime(value: any): number {
if (value instanceof Date) {
return new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime();
}
if (Array.isArray(value)) {
const [
year,
month,
day
] = value;
return new Date(year, (month ?? 1) - 1, day ?? 1).getTime();
}
return new Date(value).getTime();
}
/** True when the cap is the pre-maturity balance, so the figures shown are a floor rather than exact. */
get remainderIsApproximate(): boolean {
return !this.processingOnOrAfterMaturity;
}
iconFor(id: number): string {
return PaymentDialogComponent.ACTION_ICONS[id] ?? 'circle';
}
descriptionFor(id: number): string {
return PaymentDialogComponent.ACTION_DESCRIPTIONS[id] ?? '';
}
selectClosureType(id: number): void {
this.paymentForm.get('onAccountClosureId').setValue(id);
this.paymentForm.get('onAccountClosureId').markAsTouched();
}
/** Adds/removes the destination-account and partial-amount controls to match what the chosen type needs. */
private onClosureTypeChange(id: number): void {
const needsDestinationAccount =
id === PaymentDialogComponent.TRANSFER_TO_SAVINGS_ID ||
id === PaymentDialogComponent.PARTIAL_REINVEST_ID ||
id === PaymentDialogComponent.REINVEST_PRINCIPAL_ID;
if (needsDestinationAccount) {
if (!this.paymentForm.contains('toSavingsAccountId')) {
this.paymentForm.addControl('toSavingsAccountId', new UntypedFormControl('', Validators.required));
this.paymentForm.addControl('transferDescription', new UntypedFormControl(''));
}
} else {
this.paymentForm.removeControl('toSavingsAccountId');
this.paymentForm.removeControl('transferDescription');
}
if (id === PaymentDialogComponent.PARTIAL_REINVEST_ID) {
if (!this.paymentForm.contains('reinvestAmount')) {
this.paymentForm.addControl(
'reinvestAmount',
new UntypedFormControl('', [
Validators.required,
Validators.min(0.01),
this.belowMaturityAmountValidator()
])
);
}
} else {
this.paymentForm.removeControl('reinvestAmount');
}
}
/** Mirrors the backend's own `0 < reinvestAmount < accountBalance` rule, so a bad amount fails before submit. */
private belowMaturityAmountValidator(): ValidatorFn {
return (control) => {
const cap = Number(this.reinvestCap);
if (!cap || control.value === null || control.value === '') {
return null;
}
return Number(control.value) >= cap ? { belowMaturityAmount: true } : null;
};
}
get isPartialReinvest(): boolean {
return this.paymentForm?.get('onAccountClosureId')?.value === PaymentDialogComponent.PARTIAL_REINVEST_ID;
}
/** True when the chosen action needs a destination account but the customer has none to pick. */
get noDestinationAccounts(): boolean {
return (
!this.loadingTemplate && this.paymentForm?.contains('toSavingsAccountId') && this.savingsAccountsData.length === 0
);
}
/** What leaves this FD when the entered partial amount is kept behind; null until both figures are known. */
get remainderAmount(): number | null {
const reinvestAmount = this.paymentForm?.get('reinvestAmount')?.value;
if (!this.isPartialReinvest || reinvestAmount === null || reinvestAmount === '' || !this.reinvestCap) {
return null;
}
return Number(this.reinvestCap) - Number(reinvestAmount);
}
submit(): void {
const formValue = { ...this.paymentForm.value };
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
if (formValue.closedOnDate instanceof Date) {
formValue.closedOnDate = this.dateUtils.formatDate(formValue.closedOnDate, dateFormat);
}
const data = { ...formValue, dateFormat, locale };
this.submitting = true;
this.submitError = null;
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'payment', data)
.subscribe({
next: () => {
this.dialogRef.close({ confirm: true });
},
// Keep the dialog open with the entered values and say what went wrong, rather than leaving a
// permanently disabled Confirm behind whatever modal the global interceptor threw up.
error: (error: any) => {
this.submitting = false;
this.submitError =
error?.error?.defaultUserMessage ??
error?.error?.errors?.[0]?.defaultUserMessage ??
'This payment could not be processed. Please review the details and try again.';
}
});
}
}
@@ -37,7 +37,7 @@
<span class="fd-hero__meta-item">
<span class="fd-hero__meta-label">{{ 'labels.inputs.Account Number' | translate }}</span>
<mifosx-account-number
accountNo="{{ fixedDepositsAccountData.accountNo }}"
accountNo="{{ fixedDepositsAccountData.fdAccountNumber || fixedDepositsAccountData.accountNo }}"
display="left"
></mifosx-account-number>
</span>
@@ -230,6 +230,19 @@
<span>{{ 'labels.inputs.Transactions' | translate }}</span>
</a>
}
@if (fixedDepositsAccountData.renewalHistory?.length) {
<a
mat-tab-link
class="fd-tab"
[routerLink]="['./renewal-history']"
routerLinkActive
#renewalHistory="routerLinkActive"
[active]="renewalHistory.isActive"
>
<fa-icon icon="history" class="fd-tab__ic"></fa-icon>
<span>{{ 'labels.inputs.Renewal History' | translate }}</span>
</a>
}
<a
mat-tab-link
class="fd-tab"
@@ -16,6 +16,7 @@ import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/conf
import { CalculateInterestDialogComponent } from './custom-dialogs/calculate-interest-dialog/calculate-interest-dialog.component';
import { PostInterestDialogComponent } from './custom-dialogs/post-interest-dialog/post-interest-dialog.component';
import { ToggleWithholdTaxDialogComponent } from './custom-dialogs/toggle-withhold-tax-dialog/toggle-withhold-tax-dialog.component';
import { PaymentDialogComponent } from './custom-dialogs/payment-dialog/payment-dialog.component';
import { DepositCertificateDialogComponent } from 'app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component';
/** Custom Button Config. */
@@ -99,6 +100,7 @@ export class FixedDepositAccountViewComponent implements OnInit {
'Post Interest': 'interest',
'Enable Withhold Tax': 'interest',
'Disable Withhold Tax': 'interest',
Payment: 'transact',
'Premature Close': 'danger',
'Undo Activation': 'admin',
Close: 'danger',
@@ -122,6 +124,7 @@ export class FixedDepositAccountViewComponent implements OnInit {
'Post Interest': 'percentage',
'Enable Withhold Tax': 'percentage',
'Disable Withhold Tax': 'percentage',
Payment: 'sync-alt',
'Premature Close': 'times-circle',
'Undo Activation': 'undo',
Close: 'times-circle',
@@ -261,9 +264,35 @@ export class FixedDepositAccountViewComponent implements OnInit {
case 'Terminate':
this.terminateFixedDepositsAccount();
break;
case 'Payment':
this.openPaymentDialog();
break;
}
}
/**
* Opens the Payment action -- unlike Premature Close/Close, works whether the account is Active or
* Matured, and is the only place a Partial Reinvest can be done.
*/
private openPaymentDialog(): void {
const paymentDialogRef = this.dialog.open(PaymentDialogComponent, {
width: '640px',
maxWidth: '95vw',
data: {
accountId: this.fixedDepositsAccountData.id,
// The server bounds a partial reinvest by the balance AFTER it posts interest to the chosen date,
// so the dialog needs both figures plus the maturity date to know which one that will be.
accountBalance: this.fixedDepositsAccountData.summary?.accountBalance,
maturityDate: this.fixedDepositsAccountData.maturityDate
}
});
paymentDialogRef.afterClosed().subscribe((response: any) => {
if (response?.confirm) {
this.reload();
}
});
}
/**
* Deletes Fixed Deposits Account.
*/
@@ -116,6 +116,9 @@ export class FixedDepositsButtonsConfiguration {
},
{
name: 'Add Charge'
},
{
name: 'Payment'
}
];
break;
@@ -83,6 +83,22 @@
}}</span>
</div>
}
@if (fixedDepositsAccountData.reinvestedFromAccountNo) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Reinvested From' | translate }}</span>
<span class="nx-row__value">
<a [routerLink]="reinvestedFromLink">{{ fixedDepositsAccountData.reinvestedFromAccountNo }}</a>
</span>
</div>
}
@if (fixedDepositsAccountData.reinvestedIntoAccountNo) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Reinvested Into' | translate }}</span>
<span class="nx-row__value">
<a [routerLink]="reinvestedIntoLink">{{ fixedDepositsAccountData.reinvestedIntoAccountNo }}</a>
</span>
</div>
}
</div>
</div>
@@ -48,4 +48,25 @@ export class GeneralTabComponent {
this.entityType = 'Center';
}
}
/**
* A reinvested FD always keeps the same client/group/center as the account it came from, so the
* counterpart account's view page sits at the same route -- only the account-id segment differs.
*/
private siblingAccountLink(targetAccountId: number): string | null {
const segments = this.router.url.split('?')[0].split('/').filter(Boolean);
const idx = segments.indexOf('fixed-deposits-accounts');
if (idx === -1) return null;
return '/' + segments.slice(0, idx + 1).join('/') + '/' + targetAccountId + '/general';
}
get reinvestedFromLink(): string | null {
const id = this.fixedDepositsAccountData?.reinvestedFromAccountId;
return id ? this.siblingAccountLink(id) : null;
}
get reinvestedIntoLink(): string | null {
const id = this.fixedDepositsAccountData?.reinvestedIntoAccountId;
return id ? this.siblingAccountLink(id) : null;
}
}
@@ -36,6 +36,17 @@
<td mat-cell *matCellDef="let chartSlab">{{ chartSlab.annualInterestRate | formatNumber }} %</td>
</ng-container>
<ng-container matColumnDef="seniorCitizen">
<th mat-header-cell *matHeaderCellDef><fa-icon icon="user-shield" class="m-r-5"></fa-icon>Senior Citizen</th>
<td mat-cell *matCellDef="let chartSlab">
{{
chartSlab.seniorCitizenRate !== null && chartSlab.seniorCitizenRate !== undefined
? (chartSlab.seniorCitizenRate | formatNumber) + ' %'
: '-'
}}
</td>
</ng-container>
<ng-container matColumnDef="description">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Description' | translate }}</th>
<td mat-cell *matCellDef="let chartSlab">{{ chartSlab.description }}</td>
@@ -71,6 +71,7 @@ export class InterestRateChartTabComponent {
'period',
'amountRange',
'interest',
'seniorCitizen',
'description',
'actions'
];
@@ -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/.
-->
<div class="rh-container">
@if (renewalHistory.length) {
<!-- ─── Summary strip ──────────────────────────────────────────── -->
<div class="rh-summary">
<div class="rh-summary__item">
<span class="rh-summary__label">FD Number</span>
<span class="rh-summary__value">{{ fdNumber }}</span>
</div>
<div class="rh-summary__item">
<span class="rh-summary__label">Terms So Far</span>
<span class="rh-summary__value">{{ renewalHistory.length }}</span>
</div>
<div class="rh-summary__item">
<span class="rh-summary__label">Original Principal</span>
<span class="rh-summary__value">{{ originalPrincipal | currency: currency.code : 'symbol-narrow' }}</span>
</div>
<div class="rh-summary__item rh-summary__item--highlight">
<span class="rh-summary__label">Current Value</span>
<span class="rh-summary__value">{{ currentValue | currency: currency.code : 'symbol-narrow' }}</span>
</div>
<div class="rh-summary__item rh-summary__item--positive">
<span class="rh-summary__label">Lifetime Interest</span>
<span class="rh-summary__value">{{ lifetimeInterestEarned | currency: currency.code : 'symbol-narrow' }}</span>
</div>
</div>
<!-- ─── Vertical timeline ──────────────────────────────────────── -->
<div class="rh-timeline">
@for (
term of renewalHistory;
track term.historyid ?? term.accountid + '-' + term.renewalcycleno;
let last = $last
) {
<div class="rh-node">
<div class="rh-node__rail">
<span class="rh-node__dot" [class.rh-node__dot--active]="term.renewalstatus === 'ACTIVE'">
@if (term.renewalstatus === 'ACTIVE') {
<span class="rh-node__pulse"></span>
}
</span>
@if (!last) {
<span class="rh-node__line"></span>
}
</div>
<div class="rh-card" [class.rh-card--active]="term.renewalstatus === 'ACTIVE'">
<div class="rh-card__head">
<div class="rh-card__title-group">
<h4 class="rh-card__title">
{{ term.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + term.renewalcycleno }}
</h4>
<span class="rh-badge" [class.rh-badge--active]="term.renewalstatus === 'ACTIVE'">
<fa-icon [icon]="term.renewalstatus === 'ACTIVE' ? 'check-circle' : 'clock'"></fa-icon>
{{ term.renewalstatus === 'ACTIVE' ? 'Active' : 'Matured' }}
</span>
@if (renewalTypeLabel(term)) {
<span class="rh-tag">
<fa-icon [icon]="renewalTypeIcon(term)"></fa-icon>
{{ renewalTypeLabel(term) }}
</span>
}
</div>
<button type="button" class="rh-chart-btn" (click)="openRateChart(term)">
<fa-icon icon="table"></fa-icon>
View Rate Chart
</button>
</div>
<div class="rh-card__period">
<fa-icon icon="calendar-alt" class="rh-card__period-ico"></fa-icon>
<span>{{ term.activatedondate | dateFormat }}</span>
<fa-icon icon="arrow-right" class="rh-card__period-arrow"></fa-icon>
<span>{{ term.maturitydate | dateFormat }}</span>
@if (tenureLabel(term)) {
<span class="rh-card__tenure">({{ tenureLabel(term) }})</span>
}
</div>
<div class="rh-stats">
<div class="rh-stat">
<span class="rh-stat__label">Principal</span>
<span class="rh-stat__value">{{ term.depositamount | currency: currency.code : 'symbol-narrow' }}</span>
</div>
<div class="rh-stat">
<span class="rh-stat__label">Rate</span>
<span class="rh-stat__value">{{ term.interestrate }}%</span>
</div>
<div class="rh-stat">
<span class="rh-stat__label">{{
term.renewalstatus === 'ACTIVE' ? 'Interest So Far' : 'Interest Earned'
}}</span>
<span class="rh-stat__value rh-stat__value--positive">
{{
term.interestearned === null || term.interestearned === undefined
? '—'
: (term.interestearned | currency: currency.code : 'symbol-narrow')
}}
</span>
</div>
<div class="rh-stat rh-stat--highlight">
<span class="rh-stat__label">{{
term.maturedamount === null || term.maturedamount === undefined ? 'Projected Maturity' : 'Matured At'
}}</span>
<span class="rh-stat__value">
{{ maturityFor(term) | currency: currency.code : 'symbol-narrow' }}
</span>
</div>
</div>
<div class="rh-card__foot">
<fa-icon icon="info-circle" class="rh-card__foot-ico"></fa-icon>
<span>
Ledger account:
@if (term.accountid === fixedDepositsAccountData.id) {
<strong>{{ term.accountno }}</strong>
} @else {
<a [routerLink]="siblingLink(term.accountid)">{{ term.accountno }}</a>
}
</span>
</div>
</div>
</div>
}
</div>
} @else {
<div class="rh-empty">
<fa-icon icon="history" class="rh-empty__icon"></fa-icon>
<p class="rh-empty__text">{{ 'labels.messages.No Renewal History Available' | translate }}</p>
</div>
}
</div>
@@ -0,0 +1,364 @@
/**
* 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/.
*/
$navy: #0d1b2a;
$slate: #475569;
$muted: #64748b;
$border: #e2e8f0;
$soft: #f8fafc;
$positive: #047857;
$positive-soft: #ecfdf5;
$accent: #2563eb;
$accent-soft: #eff6ff;
.rh-container {
max-width: 860px;
margin: 0 auto;
padding: 4px 0 24px;
}
/* ─── Summary strip ─────────────────────────────────────────────────── */
.rh-summary {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 1px;
background: $border;
border: 1px solid $border;
border-radius: 14px;
overflow: hidden;
margin-bottom: 28px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
&__item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 16px;
background: #fff;
}
&__label {
font-size: 10.5px;
font-weight: 600;
color: $muted;
text-transform: uppercase;
letter-spacing: 0.04em;
}
&__value {
font-size: 15px;
font-weight: 700;
color: $navy;
font-variant-numeric: tabular-nums;
}
&__item--highlight {
background: $accent-soft;
.rh-summary__value {
color: $accent;
}
}
&__item--positive {
background: $positive-soft;
.rh-summary__value {
color: $positive;
}
}
}
@media (width <= 720px) {
.rh-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* ─── Timeline rail ──────────────────────────────────────────────────── */
.rh-timeline {
display: flex;
flex-direction: column;
}
.rh-node {
display: flex;
gap: 18px;
}
.rh-node__rail {
flex: none;
width: 20px;
display: flex;
flex-direction: column;
align-items: center;
}
.rh-node__dot {
position: relative;
width: 16px;
height: 16px;
margin-top: 22px;
border-radius: 50%;
background: #cbd5e1;
border: 3px solid #fff;
box-shadow: 0 0 0 2px #cbd5e1;
flex: none;
&--active {
background: $positive;
box-shadow: 0 0 0 2px $positive;
}
}
.rh-node__pulse {
position: absolute;
inset: -6px;
border-radius: 50%;
border: 2px solid $positive;
opacity: 0.6;
animation: rh-pulse 1.8s ease-out infinite;
}
@keyframes rh-pulse {
0% {
transform: scale(0.6);
opacity: 0.7;
}
100% {
transform: scale(1.6);
opacity: 0;
}
}
.rh-node__line {
flex: 1;
width: 2px;
min-height: 24px;
background: $border;
margin: 4px 0;
}
/* ─── Term card ──────────────────────────────────────────────────────── */
.rh-card {
flex: 1;
min-width: 0;
background: #fff;
border: 1px solid $border;
border-radius: 14px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
padding: 16px 18px;
margin-bottom: 20px;
transition: box-shadow 0.15s ease;
&--active {
border-color: rgb(4 120 87 / 20%);
box-shadow: 0 2px 10px rgb(4 120 87 / 8%);
}
&:hover {
box-shadow: 0 4px 14px rgb(15 23 42 / 8%);
}
&__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
&__title-group {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
&__title {
margin: 0;
font-size: 15px;
font-weight: 700;
color: $navy;
}
&__period {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
font-size: 12.5px;
color: $slate;
font-weight: 500;
&-ico {
color: $muted;
font-size: 11px;
}
&-arrow {
color: $muted;
font-size: 10px;
}
}
&__tenure {
color: $muted;
font-weight: 400;
}
&__foot {
display: flex;
align-items: center;
gap: 6px;
margin-top: 12px;
padding-top: 10px;
border-top: 1px dashed $border;
font-size: 11.5px;
color: $muted;
&-ico {
font-size: 10px;
}
a {
color: $accent;
font-weight: 600;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
/* ─── Status badge / auto-renewed tag ───────────────────────────────── */
.rh-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 9px;
border-radius: 999px;
background: $soft;
color: $muted;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
&--active {
background: $positive-soft;
color: $positive;
}
}
.rh-tag {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 9px;
border-radius: 999px;
background: $accent-soft;
color: $accent;
font-size: 10.5px;
font-weight: 600;
}
/* ─── View Rate Chart button ─────────────────────────────────────────── */
.rh-chart-btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 14px;
border: 1px solid $border;
border-radius: 999px;
background: #fff;
color: $accent;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
&:hover {
background: $accent-soft;
border-color: $accent;
}
}
/* ─── Stat grid ──────────────────────────────────────────────────────── */
.rh-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-top: 14px;
}
@media (width <= 640px) {
.rh-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.rh-stat {
display: flex;
flex-direction: column;
gap: 3px;
padding: 9px 12px;
background: $soft;
border-radius: 10px;
&__label {
font-size: 10px;
font-weight: 600;
color: $muted;
text-transform: uppercase;
letter-spacing: 0.03em;
}
&__value {
font-size: 13.5px;
font-weight: 700;
color: $navy;
font-variant-numeric: tabular-nums;
&--positive {
color: $positive;
}
}
&--highlight {
background: $accent-soft;
.rh-stat__value {
color: $accent;
}
}
}
/* ─── Empty state ────────────────────────────────────────────────────── */
.rh-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 64px 16px;
color: $muted;
&__icon {
font-size: 30px;
opacity: 0.4;
}
&__text {
margin: 0;
font-size: 13px;
}
}
@@ -0,0 +1,235 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { Currency } from 'app/shared/models/general.model';
import { CurrencyPipe } from '@angular/common';
import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ProductsService } from 'app/products/products.service';
import { TermRateChartDialogComponent } from './term-rate-chart-dialog/term-rate-chart-dialog.component';
/**
* Every term of one FD, oldest first, as a vertical timeline -- one card per renewal cycle. Its own tab
* rather than a card on General, since a long-lived FD can carry many terms and each one's full detail
* (dates, rate, principal, interest, and its own rate chart) needs real room to read comfortably.
*/
@Component({
selector: 'mifosx-renewal-history-tab',
templateUrl: './renewal-history-tab.component.html',
styleUrls: ['./renewal-history-tab.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
CurrencyPipe,
DateFormatPipe
]
})
export class RenewalHistoryTabComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private dialog = inject(MatDialog);
private productsService = inject(ProductsService);
fixedDepositsAccountData: any;
currency: Currency;
/**
* The bank's currently published senior-citizen premium per tenure band, for this FD's type. Fetched once
* from the same CBS Base Interest Rates screen staff maintain, so a term's rate-chart popup can show the
* premium alongside the standard rate -- neither the account's own chart snapshot nor the archived history
* carries a senior-citizen column, since that concept only exists on the base rate master.
*/
private activeBaseSlabs: any[] = [];
constructor() {
this.route.parent.data.subscribe((data: { fixedDepositsAccountData: any }) => {
this.fixedDepositsAccountData = data.fixedDepositsAccountData;
this.currency = this.fixedDepositsAccountData.currency;
});
}
ngOnInit(): void {
const fdType = this.fixedDepositsAccountData?.fdType;
if (!fdType) {
return;
}
this.productsService.getFdBaseInterestRates(fdType).subscribe({
next: (rates: any[]) => {
const active = (rates || []).find((r: any) => r.isActive);
this.activeBaseSlabs = active?.slabs ?? [];
},
error: () => {}
});
}
/**
* The published senior-citizen rate for a tenure band, matched by period type and range against the active
* base rate card. Returns null when nothing matches (no published base version for this FD type, or this
* particular band isn't on it, or the matching band simply carries no premium) -- displayed as a blank
* rather than a misleading zero.
*/
private seniorCitizenRateFor(periodTypeEnum: number, fromPeriod: number, toPeriod: number | null): number | null {
const match = this.activeBaseSlabs.find(
(s) =>
s.periodTypeEnum === periodTypeEnum &&
s.fromPeriod === fromPeriod &&
(s.toPeriod ?? null) === (toPeriod ?? null)
);
return match?.seniorCitizenRate > 0 ? match.seniorCitizenRate : null;
}
/**
* A reinvested FD always keeps the same client/group/center as the account it came from, so the
* counterpart account's view page sits at the same route -- only the account-id segment differs.
*/
private siblingAccountLink(targetAccountId: number): string | null {
const segments = this.router.url.split('?')[0].split('/').filter(Boolean);
const idx = segments.indexOf('fixed-deposits-accounts');
if (idx === -1) return null;
return '/' + segments.slice(0, idx + 1).join('/') + '/' + targetAccountId + '/general';
}
siblingLink(targetAccountId: number): string | null {
return this.siblingAccountLink(targetAccountId);
}
/**
* Every term of this FD, oldest first, as resolved by the backend. Keys are lower-cased because they
* arrive straight from the SQL result-set column labels.
*/
get renewalHistory(): any[] {
return this.fixedDepositsAccountData?.renewalHistory ?? [];
}
get fdNumber(): string {
return this.fixedDepositsAccountData?.fdAccountNumber ?? this.fixedDepositsAccountData?.accountNo;
}
/** The very first term's opening principal -- what the customer originally deposited. */
get originalPrincipal(): number | null {
return this.renewalHistory[0]?.depositamount ?? null;
}
/** The latest term's settled or projected value -- what the FD is worth right now. */
get currentValue(): number | null {
const latest = this.renewalHistory[this.renewalHistory.length - 1];
return latest ? this.maturityFor(latest) : null;
}
/**
* Total interest earned across every term, settled terms plus the running term's interest-to-date. Null
* only when there is nothing to sum, so the summary strip can tell "no data yet" apart from "zero".
*/
get lifetimeInterestEarned(): number | null {
if (!this.renewalHistory.length) {
return null;
}
let total = 0;
for (const term of this.renewalHistory) {
if (term.interestearned != null) {
total += Number(term.interestearned);
} else {
const projected = this.maturityFor(term);
if (projected != null && term.depositamount != null) {
total += Number(projected) - Number(term.depositamount);
}
}
}
return total;
}
/**
* A term's settled maturity amount, or its projection while it is still running. Resolved here rather
* than inline so the choice isn't left to operator precedence between `??` and the currency pipe.
*/
maturityFor(term: any): number | null {
return term?.maturedamount ?? term?.projectedmaturityamount ?? null;
}
/** periodTypeEnum values as archived -- same encoding as SavingsPeriodFrequencyType (DAYS=0 ... YEARS=3). */
private static readonly PERIOD_TYPE_LABELS: Record<number, string> = {
0: 'Days',
1: 'Weeks',
2: 'Months',
3: 'Years'
};
/** "10 Years" rather than a bare number, using the same period-type encoding the archived charts use. */
tenureLabel(term: any): string {
if (term?.tenure == null) {
return '';
}
const unit = RenewalHistoryTabComponent.PERIOD_TYPE_LABELS[term.tenurefrequencyenum] ?? '';
return unit ? `${term.tenure} ${unit}` : `${term.tenure}`;
}
/** How the renewal happened, for display as a small tag -- null when the term hasn't renewed (yet). */
renewalTypeLabel(term: any): string | null {
if (term?.renewaltype === 'AUTO') return 'Auto-Renewed';
if (term?.renewaltype === 'MANUAL') return 'Renewed by Staff';
return null;
}
renewalTypeIcon(term: any): string {
return term?.renewaltype === 'MANUAL' ? 'user-cog' : 'robot';
}
/**
* The rate-by-tenure chart for one term of this FD's history, normalised to one shape regardless of source.
*
* A term's chart is only archived once it closes via an in-place renewal, so the term still running has no
* archived rows -- for that one term (recognisable as the one matching this account's own id), the
* account's own live chart is shown instead, since it is the same data. The two sources use different key
* casing and represent the period type differently (a raw enum int here, an expanded {value} object on the
* live chart), which this maps into one consistent shape the dialog can render without knowing which source
* it came from.
*/
private chartSlabsFor(term: any): any[] {
const raw = term?.chartSlabs?.length
? term.chartSlabs
: term?.accountid === this.fixedDepositsAccountData?.id
? (this.fixedDepositsAccountData?.accountChart?.chartSlabs ?? [])
: [];
return raw.map((slab: any) => {
const periodTypeEnum = slab.periodType?.id ?? slab.periodtypeenum;
const fromPeriod = slab.fromperiod ?? slab.fromPeriod;
const toPeriod = slab.toperiod ?? slab.toPeriod;
// The archived/live row's own senior_citizen_rate is the true figure as published when this term
// was actually priced -- preferred whenever present. The live lookup against today's base rate
// card is only a fallback, for the currently-running term (not archived yet) or an older archive
// row written before that column existed -- flagged as such so the dialog can say so.
const persistedSeniorRate = slab.seniorcitizenrate ?? slab.seniorCitizenRate ?? null;
return {
description: slab.description,
fromPeriod,
toPeriod,
periodTypeLabel: slab.periodType?.value ?? RenewalHistoryTabComponent.PERIOD_TYPE_LABELS[periodTypeEnum] ?? '',
annualInterestRate: slab.annualinterestrate ?? slab.annualInterestRate,
seniorCitizenRate:
persistedSeniorRate ?? this.seniorCitizenRateFor(periodTypeEnum, fromPeriod, toPeriod ?? null),
seniorCitizenRateIsLive: persistedSeniorRate == null
};
});
}
/** Opens the full rate chart for one term in a popup, rather than pushing every later term down the page. */
openRateChart(term: any): void {
this.dialog.open(TermRateChartDialogComponent, {
width: '560px',
data: {
term,
slabs: this.chartSlabsFor(term),
isSeniorCitizen: !!this.fixedDepositsAccountData?.isSeniorCitizen
}
});
}
}
@@ -0,0 +1,77 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="trc-dialog">
<h2 mat-dialog-title class="trc-header">
<span class="trc-header__icon"><fa-icon icon="table"></fa-icon></span>
<span class="trc-header__text">
<span class="trc-header__title">{{ termLabel }} — Rate Chart</span>
<span class="trc-header__sub"
>{{ term.activatedondate | dateFormat }} → {{ term.maturitydate | dateFormat }}</span
>
</span>
</h2>
<div mat-dialog-content class="trc-content">
@if (slabs.length) {
<table class="trc-table">
<thead>
<tr>
<th>Description</th>
<th>Tenure</th>
<th class="trc-table__rate-col">Rate</th>
<th class="trc-table__rate-col">
<fa-icon icon="user-shield" class="trc-table__sc-ico"></fa-icon>
Senior Citizen
</th>
</tr>
</thead>
<tbody>
@for (slab of slabs; track $index) {
<tr [class.trc-table__row--applied]="isAppliedSlab(slab) || isAppliedSeniorSlab(slab)">
<td>{{ slab.description }}</td>
<td>{{ slab.fromPeriod }} – {{ slab.toPeriod || '∞' }} {{ slab.periodTypeLabel }}</td>
<td class="trc-table__rate-col">
{{ slab.annualInterestRate }}%
@if (isAppliedSlab(slab)) {
<span class="trc-applied-badge">Applied</span>
}
</td>
<td class="trc-table__rate-col trc-table__sc-col">
{{
slab.seniorCitizenRate !== null && slab.seniorCitizenRate !== undefined
? slab.seniorCitizenRate + '%'
: '—'
}}
@if (isAppliedSeniorSlab(slab)) {
<span class="trc-applied-badge">Applied</span>
}
</td>
</tr>
}
</tbody>
</table>
@if (hasLiveSeniorRate) {
<p class="trc-note">
<fa-icon icon="info-circle"></fa-icon>
Senior citizen rate for this term wasn't recorded at the time; the figure shown is today's published rate, for
reference.
</p>
}
} @else {
<div class="trc-empty">
<fa-icon icon="info-circle" class="trc-empty__icon"></fa-icon>
<p>No rate chart was archived for this term.</p>
</div>
}
</div>
<div mat-dialog-actions align="end" class="trc-actions">
<button mat-button mat-dialog-close>{{ 'labels.buttons.Close' | translate }}</button>
</div>
</div>
@@ -0,0 +1,159 @@
/**
* 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/.
*/
$navy: #0d1b2a;
$slate: #475569;
$muted: #64748b;
$border: #e2e8f0;
$soft: #f8fafc;
$positive: #047857;
$positive-soft: #ecfdf5;
$accent: #b8860b;
.trc-dialog {
min-width: 420px;
max-width: 540px;
}
.trc-header {
display: flex;
align-items: center;
gap: 12px;
margin: 0 0 4px;
&__icon {
flex: none;
width: 38px;
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%);
color: #fff;
font-size: 15px;
}
&__text {
display: flex;
flex-direction: column;
min-width: 0;
}
&__title {
font-size: 15px;
font-weight: 700;
color: $navy;
line-height: 1.3;
}
&__sub {
font-size: 12px;
color: $muted;
font-weight: 500;
}
}
.trc-content {
padding-top: 4px;
}
.trc-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th {
padding: 8px 10px;
background: $soft;
color: $muted;
font-weight: 600;
text-align: left;
border-bottom: 1px solid $border;
}
td {
padding: 9px 10px;
color: $slate;
border-bottom: 1px solid $border;
font-variant-numeric: tabular-nums;
}
&__rate-col {
text-align: right;
}
&__row--applied {
background: $positive-soft;
td {
color: $positive;
font-weight: 700;
}
}
&__sc-ico {
color: $accent;
margin-right: 4px;
}
&__sc-col {
color: $accent;
font-weight: 600;
}
}
.trc-applied-badge {
display: inline-block;
margin-left: 6px;
padding: 1px 7px;
border-radius: 10px;
background: $positive;
color: #fff;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
vertical-align: middle;
}
.trc-note {
display: flex;
align-items: flex-start;
gap: 6px;
margin: 10px 2px 0;
color: $muted;
font-size: 11px;
font-style: italic;
line-height: 1.4;
}
.trc-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 32px 8px;
color: $muted;
text-align: center;
&__icon {
font-size: 22px;
opacity: 0.5;
}
p {
margin: 0;
font-size: 13px;
}
}
.trc-actions {
padding-top: 6px;
}
@@ -0,0 +1,93 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogRef,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { MatButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* The full rate-by-tenure chart one term of an FD was priced on, in a popup rather than inline in the
* timeline: a chart can run to several tenure bands, and showing it in place would push every later term
* down the page every time one is opened.
*/
@Component({
selector: 'mifosx-term-rate-chart-dialog',
templateUrl: './term-rate-chart-dialog.component.html',
styleUrls: ['./term-rate-chart-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose,
MatButton,
FaIconComponent
]
})
export class TermRateChartDialogComponent {
dialogRef = inject<MatDialogRef<TermRateChartDialogComponent>>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
get term(): any {
return this.data.term;
}
get slabs(): any[] {
return this.data.slabs ?? [];
}
get termLabel(): string {
return this.term?.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + this.term?.renewalcycleno;
}
/** Whether the DEPOSITOR (not just the tenure band) was a senior citizen for this term. */
get isSeniorCitizen(): boolean {
return !!this.data.isSeniorCitizen;
}
/**
* True when at least one senior-citizen figure shown is a live lookup against today's base rate card rather than the
* true rate persisted for this specific term -- only possible for the currently-running term (not archived yet) or an
* older archive row that predates the persisted column. Drives whether the disclaimer note is shown at all.
*/
get hasLiveSeniorRate(): boolean {
return this.slabs.some((s) => s.seniorCitizenRateIsLive && s.seniorCitizenRate != null);
}
/**
* True for the STANDARD-rate cell that was actually applied to this term. Never true for a senior-citizen
* term, since that term was priced off the senior column instead -- see {@link isAppliedSeniorSlab}.
* Compared as numbers since one side arrives as a string from the currency-agnostic archive row.
*/
isAppliedSlab(slab: any): boolean {
return (
!this.isSeniorCitizen &&
this.term?.interestrate != null &&
Number(slab.annualInterestRate) === Number(this.term.interestrate)
);
}
/** True for the SENIOR-CITIZEN cell that was actually applied, for a term held by a senior-citizen depositor. */
isAppliedSeniorSlab(slab: any): boolean {
return (
this.isSeniorCitizen &&
slab.seniorCitizenRate != null &&
this.term?.interestrate != null &&
Number(slab.seniorCitizenRate) === Number(this.term.interestrate)
);
}
}
@@ -36,7 +36,11 @@
[ngClass]="savingsTransactionColor(transaction)"
(click)="showTransactions(transaction)"
>
{{ transaction.id }}
@if (transaction.isRenewalEvent) {
<fa-icon icon="sync-alt" class="txn-tab__renewal-ico"></fa-icon>
} @else {
{{ transaction.id }}
}
</td>
</ng-container>
@@ -72,7 +76,13 @@
[ngClass]="savingsTransactionColor(transaction)"
(click)="showTransactions(transaction)"
>
{{ isDebit(transaction.transactionType) ? (transaction.amount | formatNumber) : 'N/A' }}
{{
transaction.isRenewalEvent
? '—'
: isDebit(transaction.transactionType)
? (transaction.amount | formatNumber)
: 'N/A'
}}
</td>
</ng-container>
@@ -84,7 +94,13 @@
[ngClass]="savingsTransactionColor(transaction)"
(click)="showTransactions(transaction)"
>
{{ !isDebit(transaction.transactionType) ? (transaction.amount | formatNumber) : 'N/A' }}
{{
transaction.isRenewalEvent
? '—'
: !isDebit(transaction.transactionType)
? (transaction.amount | formatNumber)
: 'N/A'
}}
</td>
</ng-container>
@@ -96,36 +112,40 @@
[ngClass]="savingsTransactionColor(transaction)"
(click)="showTransactions(transaction)"
>
{{ transaction.runningBalance | formatNumber }}
{{ transaction.isRenewalEvent ? '—' : (transaction.runningBalance | formatNumber) }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell class="center" *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell class="center" *matCellDef="let transaction; let idx = index">
<button mat-icon-button [matMenuTriggerFor]="transactionMenu" aria-label="" class="action-button">
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #transactionMenu="matMenu">
<button mat-menu-item (click)="showTransactions(transaction)">
<mat-icon><fa-icon icon="eye" size="sm"></fa-icon></mat-icon>
<span>{{ 'labels.text.View Transaction' | translate }}</span>
@if (transaction.isRenewalEvent) {
<span class="txn-tab__renewal-tag">No ledger impact</span>
} @else {
<button mat-icon-button [matMenuTriggerFor]="transactionMenu" aria-label="" class="action-button">
<mat-icon>more_vert</mat-icon>
</button>
@if (!isFirstTransaction(idx)) {
<button mat-menu-item (click)="undoTransaction(transaction)">
<mat-icon><fa-icon icon="undo" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.Undo Transaction' | translate }}</span>
<mat-menu #transactionMenu="matMenu">
<button mat-menu-item (click)="showTransactions(transaction)">
<mat-icon><fa-icon icon="eye" size="sm"></fa-icon></mat-icon>
<span>{{ 'labels.text.View Transaction' | translate }}</span>
</button>
}
<button
mat-menu-item
(click)="routeEdit($event)"
[routerLink]="['/', 'journal-entry', 'view', 'S' + transaction.id]"
>
<mat-icon><fa-icon icon="table-cells" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.View Journal Entries' | translate }}</span>
</button>
</mat-menu>
@if (!isFirstTransaction(idx)) {
<button mat-menu-item (click)="undoTransaction(transaction)">
<mat-icon><fa-icon icon="undo" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.Undo Transaction' | translate }}</span>
</button>
}
<button
mat-menu-item
(click)="routeEdit($event)"
[routerLink]="['/', 'journal-entry', 'view', 'S' + transaction.id]"
>
<mat-icon><fa-icon icon="table-cells" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.View Journal Entries' | translate }}</span>
</button>
</mat-menu>
}
</td>
</ng-container>
@@ -97,4 +97,32 @@ table {
color: $muted;
font-style: italic;
}
.mat-mdc-cell.renewal-marker {
background: #eff6ff;
color: $blue;
font-style: italic;
cursor: default;
}
.mat-mdc-row:has(.renewal-marker):hover {
background: #eff6ff;
cursor: default;
}
}
.txn-tab__renewal-ico {
color: $blue;
}
.txn-tab__renewal-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
background: #dbeafe;
color: $blue;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
@@ -113,13 +113,71 @@ export class TransactionsTabComponent implements OnInit {
*/
constructor() {
this.route.parent.data.subscribe((data: { fixedDepositsAccountData: any }) => {
this.transactionsData = data.fixedDepositsAccountData.transactions;
this.transactionsData = this.withRenewalMarkers(
data.fixedDepositsAccountData.transactions,
data.fixedDepositsAccountData.renewalHistory
);
this.clientId = this.route.parent.snapshot.params['clientId'];
this.accountId = this.route.parent.snapshot.params['fixedDepositAccountId'];
this.status = data.fixedDepositsAccountData.status.value;
});
}
/**
* An in-place renewal moves no money -- the matured balance simply becomes the new term's opening
* principal -- so it posts no withdrawal, deposit, or journal entry (see
* DepositAccountDomainServiceJpa#handleFDAccountMaturityClosure). Without something here, a renewal
* would be invisible in this list even though the account's rate and term just changed. These rows
* are synthesized purely for display from the renewal history already loaded for the Renewal History
* tab -- never written anywhere, never matched against a real transaction id -- so undo, journal-entry
* lookup, and every real transaction's running balance stay exactly as they were.
*/
private withRenewalMarkers(transactions: any[], renewalHistory: any[]): any[] {
const markers: any[] = (renewalHistory ?? [])
.filter((term: any) => term.renewalcycleno > 0)
.map((term: any) => {
const marker: any = {
isRenewalEvent: true,
id: null,
date: term.activatedondate,
amount: null,
runningBalance: null,
reversed: false,
transfer: null,
renewalCycleNo: term.renewalcycleno,
transactionType: {
value: term.renewaltype === 'MANUAL' ? 'FD Renewed (Staff)' : 'FD Auto-Renewed',
accrual: false,
withdrawal: false,
feeDeduction: false,
overdraftInterest: false,
withholdTax: false
}
};
return marker;
});
return [
...(transactions ?? []),
...markers
].sort((a, b) => this.toEpoch(b.date) - this.toEpoch(a.date));
}
/** Same [yyyy, MM, dd]-or-ISO-string shape DateFormatPipe already handles, resolved to a sortable epoch. */
private toEpoch(value: any): number {
if (!value) {
return 0;
}
if (Array.isArray(value)) {
const [
year,
month,
day
] = value;
return new Date(year, (month ?? 1) - 1, day ?? 1).getTime();
}
return new Date(value).getTime();
}
ngOnInit() {
this.hideAccrualsParam = new UntypedFormControl(false);
this.hideReversedParam = new UntypedFormControl(false);
@@ -161,6 +219,9 @@ export class TransactionsTabComponent implements OnInit {
* @param {any} transactionsData Transactions Data
*/
showTransactions(transactionsData: any) {
if (transactionsData.isRenewalEvent) {
return;
}
if (transactionsData.transfer) {
this.router.navigate([`account-transfers/account-transfers/${transactionsData.transfer.id}`], {
relativeTo: this.route
@@ -191,7 +252,10 @@ export class TransactionsTabComponent implements OnInit {
this.dataSource.sort = this.sort;
}
savingsTransactionColor(transaction: SavingsAccountTransaction): string {
savingsTransactionColor(transaction: any): string {
if (transaction.isRenewalEvent) {
return 'renewal-marker';
}
if (transaction.reversed) {
return 'strike';
} else if (transaction.transfer) {
@@ -31,6 +31,7 @@ import { FixedDepositsAccountActionsResolver } from './common-resolvers/fixed-de
import { FixedDepositsAccountTemplateResolver } from './common-resolvers/fixed-deposit-account-template.resolver';
import { FixedDepositsAccountAndTemplateResolver } from './common-resolvers/fixed-deposit-account-and-template.resolver';
import { GeneralTabComponent } from './fixed-deposit-account-view/general-tab/general-tab.component';
import { RenewalHistoryTabComponent } from './fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component';
import { AccountNomineesTabComponent } from 'app/shared/account-nominees-tab/account-nominees-tab.component';
const routes: Routes = [
@@ -80,6 +81,15 @@ const routes: Routes = [
routeParamBreadcrumb: false
}
},
{
path: 'renewal-history',
component: RenewalHistoryTabComponent,
data: {
title: 'Fixed Deposit Account Renewal History',
breadcrumb: 'Renewal History',
routeParamBreadcrumb: false
}
},
{
path: 'transactions',
component: TransactionsTabComponent,
@@ -87,6 +87,17 @@ export class FixedDepositsService {
return this.http.get(`/fixeddepositaccounts/${accountId}/template`, { params: httpParams });
}
/**
* Template for the Payment action. Same shape as the closure template, but its `onAccountClosureOptions` also
* include Partial Re-Invest, which the closure commands exclude because only Payment implements it.
* @param {string} accountId Fixed Deposits Account Id
* @returns {Observable<any>}
*/
getFixedDepositsAccountPaymentTemplate(accountId: string): Observable<any> {
const httpParams = new HttpParams().set('command', 'payment');
return this.http.get(`/fixeddepositaccounts/${accountId}/template`, { params: httpParams });
}
/**
* @param {string} accountId Fixed Deposits Account Id
* @returns {Observable<any>}
@@ -33,9 +33,6 @@
<span class="flex-60">{{ loanProduct.description }}</span>
</div>
}
<ng-container
*ngTemplateOutlet="legacySections; context: { sections: customFieldSectionsByStep.details }"
></ng-container>
<h3 class="mat-h3 flex-fill">{{ 'labels.inputs.Currency' | translate }}</h3>
<mat-divider [inset]="true"></mat-divider>
<div class="flex-fill layout-row">
@@ -301,10 +298,6 @@
<span class="flex-60">{{ loanProduct.minimumDaysBetweenDisbursalAndFirstRepayment | formatNumber }}</span>
</div>
}
<ng-container
*ngTemplateOutlet="legacySections; context: { sections: customFieldSectionsByStep.terms }"
></ng-container>
<ng-container *ngTemplateOutlet="legacyInterestRateDetail"></ng-container>
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Settings' | translate }}</h3>
<mat-divider [inset]="true"></mat-divider>
@if (loanProduct.amortizationType?.value) {
@@ -748,9 +741,6 @@
</div>
</div>
}
<ng-container
*ngTemplateOutlet="legacySections; context: { sections: customFieldSectionsByStep.settings }"
></ng-container>
@if (loanProduct.charges.length) {
<div class="flex-fill layout-row-wrap responsive-column">
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Charges' | translate }}</h3>
@@ -823,9 +813,6 @@
</table>
</div>
}
<ng-container
*ngTemplateOutlet="legacySections; context: { sections: customFieldSectionsByStep.charges }"
></ng-container>
@if (isAdvancedPaymentAllocation) {
<div class="flex-100 layout-row-wrap responsive-column">
<h3 class="mat-h3 flex-100">{{ 'labels.inputs.Income capitalization' | translate }}</h3>
@@ -1284,73 +1271,5 @@
</div>
</div>
}
<ng-container
*ngTemplateOutlet="legacySections; context: { sections: customFieldSectionsByStep.accounting }"
></ng-container>
</div>
}
<!--
Legacy "Account Type Creation" tables. Rendered once here and pulled into each tab's own summary
above via ngTemplateOutlet, so a section sits with the tab it was entered on rather than in a
separate pile at the end of the preview.
-->
<ng-template #legacySections let-sections="sections">
@for (section of sections; track section.title) {
<div class="flex-100 layout-row-wrap responsive-column custom-field-section">
<h4 class="mat-h4 flex-100">{{ section.title }}</h4>
<table class="mat-elevation-z1 flex-100" mat-table [dataSource]="section.rows">
<ng-container matColumnDef="label">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Field' | translate }}</th>
<td mat-cell *matCellDef="let row">{{ row.label }}</td>
</ng-container>
<ng-container matColumnDef="value">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Value' | translate }}</th>
<td mat-cell *matCellDef="let row">
@switch (row.kind) {
@case ('yesNo') {
{{ row.yesNo | yesNo }}
}
@case ('date') {
{{ row.date | dateFormat }}
}
@case ('number') {
{{ row.number | formatNumber }}
}
@default {
{{ row.text }}
}
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="customFieldDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: customFieldDisplayedColumns"></tr>
</table>
</div>
}
</ng-template>
<!-- The legacy Interest Rate Detail grid, shown under Terms since that is where it is filled in. -->
<ng-template #legacyInterestRateDetail>
@if (interestRateDetailRows.length > 0) {
<div class="flex-100 layout-row-wrap responsive-column">
<h4 class="mat-h4 flex-100">{{ 'labels.heading.Interest Rate Detail' | translate }}</h4>
<table class="mat-elevation-z1 flex-100" mat-table [dataSource]="interestRateDetailRows">
@for (column of interestRateDetailColumns; track column.key) {
<ng-container [matColumnDef]="column.key">
<th mat-header-cell *matHeaderCellDef>{{ column.label }}</th>
<td mat-cell *matCellDef="let row">
@if (column.key === 'calculation') {
{{ row.calculation | yesNo }}
} @else {
{{ row[column.key] }}
}
</td>
</ng-container>
}
<tr mat-header-row *matHeaderRowDef="interestRateDetailDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: interestRateDetailDisplayedColumns"></tr>
</table>
</div>
}
</ng-template>
@@ -56,13 +56,3 @@ mat-divider {
.repayment-strategy {
color: $status-approved;
}
// Legacy "Account Type Creation" sections. Each is a two-column field/value table, and the widest
// of them (Interest Related Parameters) is long, so the label column is pinned to keep the value
// column from drifting between one section and the next.
.custom-field-section {
th:first-of-type,
td:first-of-type {
width: 45%;
}
}
@@ -26,15 +26,6 @@ import {
PaymentAllocation
} from '../../loan-product-stepper/loan-product-payment-strategy-step/payment-allocation-model';
import { LoanProducts } from '../../loan-products';
import {
CUSTOM_FIELD_LABELS,
CUSTOM_FIELD_SECTIONS,
CUSTOM_GL_ACCOUNT_FIELDS,
CustomFieldStep,
CustomGlAccountKind,
INTEREST_RATE_DETAIL_COLUMNS,
isCustomSectionApplicable
} from '../../loan-product-custom-fields';
import { CodeName, OptionData, StringEnumOptionData } from '../../../../shared/models/option-data.model';
import { Accounting } from 'app/core/utils/accounting';
import { DecimalPipe } from '@angular/common';
@@ -60,38 +51,6 @@ import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
import { YesnoPipe } from '../../../../pipes/yesno.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* One legacy field, ready to render. `kind` picks which pipe the template applies, so formatting
* (translation, dates, numbers) stays in the template rather than being baked into a string here.
*/
export interface CustomFieldRow {
label: string;
kind: 'text' | 'yesNo' | 'date' | 'number';
text?: string;
yesNo?: boolean;
date?: Date;
number?: number;
}
export interface CustomFieldSectionView {
title: string;
rows: CustomFieldRow[];
}
/** Legacy sections keyed by the tab they belong under, so each renders inside that tab's summary. */
export type CustomFieldSectionsByStep = Record<CustomFieldStep, CustomFieldSectionView[]>;
/** A row of the legacy Interest Rate Detail grid. */
export interface InterestRateDetailRow {
interestType: string;
calculation: boolean;
normalMinRate: number | null;
normalMaxRate: number | null;
specialMinRate: number | null;
specialMaxRate: number | null;
period: string;
}
@Component({
selector: 'mifosx-loan-product-summary',
templateUrl: './loan-product-summary.component.html',
@@ -154,12 +113,6 @@ export class LoanProductSummaryComponent implements OnInit, OnChanges {
'chargeOffReasonCodeValueId',
'expenseAccountId'
];
customFieldDisplayedColumns: string[] = [
'label',
'value'
];
readonly interestRateDetailColumns = INTEREST_RATE_DETAIL_COLUMNS;
readonly interestRateDetailDisplayedColumns: string[] = INTEREST_RATE_DETAIL_COLUMNS.map((column) => column.key);
accountingRuleData: string[] = [];
isAdvancedPaymentAllocation = false;
@@ -175,15 +128,6 @@ export class LoanProductSummaryComponent implements OnInit, OnChanges {
capitalizedIncomeClassificationToIncomeAccountMappings: ClassificationToIncomeAccountMapping[] = [];
writeOffReasonsToExpenseMappings: ChargeOffReasonToExpenseAccountMapping[] = [];
/** Legacy "Account Type Creation" sections, keyed by the tab each belongs under. */
customFieldSectionsByStep: CustomFieldSectionsByStep = LoanProductSummaryComponent.emptySectionsByStep();
/** The legacy Interest Rate Detail grid, shown as its own table since its value is a row array. */
interestRateDetailRows: InterestRateDetailRow[] = [];
private static emptySectionsByStep(): CustomFieldSectionsByStep {
return { details: [], settings: [], terms: [], charges: [], accounting: [] };
}
ngOnInit() {
this.accountingRuleData = this.accounting.getAccountingRulesForLoans();
this.setCurrentValues();
@@ -574,8 +518,6 @@ export class LoanProductSummaryComponent implements OnInit, OnChanges {
this.loanProductsTemplate.advancedPaymentAllocationFutureInstallmentAllocationRules
};
}
this.setCustomFieldValues();
}
optionDataLookUp(itemId: any, optionsData: any[]): OptionData {
@@ -716,119 +658,4 @@ export class LoanProductSummaryComponent implements OnInit, OnChanges {
mapHumanReadableValueStringEnumOptionDataList(incomingParameter: StringEnumOptionData[]): string[] {
return incomingParameter.map((v) => v.value);
}
/**
* Builds the legacy "Account Type Creation" tables, which only exist on the create/edit forms
* (see CUSTOM_FIELD_SECTIONS) — reviewed here because `LoanProducts.buildPayload()` drops them
* from the submit payload before it reaches Fineract.
*
* Sections are keyed by their originating tab so each renders inside that tab's own summary. A
* "Cheque Book Settings" table is only reviewable next to the rest of Settings; collected into
* one block at the bottom, it reads as an unrelated pile.
*
* Only genuinely unset values are hidden. A field the officer deliberately set to No or
* false is part of the configuration being reviewed, so it is shown like any other answer.
*/
private setCustomFieldValues(): void {
this.customFieldSectionsByStep = LoanProductSummaryComponent.emptySectionsByStep();
this.interestRateDetailRows = [];
if (!this.loanProduct) {
return;
}
this.interestRateDetailRows = this.buildInterestRateDetailRows((this.loanProduct as any)['interestRateDetail']);
const productSubGroup: string = (this.loanProduct as any)['productTypeSubGroup'];
for (const section of CUSTOM_FIELD_SECTIONS) {
// A section the Settings step hides for this sub-group is not part of the product's
// configuration, so listing its defaulted controls here would only invite the reader to
// review settings that do not apply -- a Gold Loan has no cheque book.
if (!isCustomSectionApplicable(section.title, productSubGroup)) {
continue;
}
const rows: CustomFieldRow[] = [];
for (const field of section.fields) {
// Rendered as its own table under the Terms summary.
if (field === 'interestRateDetail') {
continue;
}
const row = this.buildCustomFieldRow(field, (this.loanProduct as any)[field]);
if (row) {
rows.push(row);
}
}
if (rows.length > 0) {
this.customFieldSectionsByStep[section.step].push({ title: section.title, rows });
}
}
}
private buildCustomFieldRow(field: string, value: any): CustomFieldRow | null {
if (value === null || value === undefined || value === '') {
return null;
}
const label = CUSTOM_FIELD_LABELS[field] ?? field;
const glAccountKind = CUSTOM_GL_ACCOUNT_FIELDS[field];
if (glAccountKind) {
const account = this.glAccountLookUp(value, this.glAccountOptionsFor(glAccountKind));
return {
label,
kind: 'text',
// An id with no matching account means the option lists have not loaded yet, so show the
// raw id rather than a blank cell.
text: account ? '(' + account.glCode + ') ' + account.name : String(value)
};
}
if (typeof value === 'boolean') {
return { label, kind: 'yesNo', yesNo: value };
}
if (value === 'YES' || value === 'NO') {
return { label, kind: 'yesNo', yesNo: value === 'YES' };
}
// Only real Date instances go through the date pipe -- a value already formatted by a step's
// getter is left as text so it is not reformatted a second time.
if (value instanceof Date) {
return { label, kind: 'date', date: value };
}
// Numeric strings such as account or MICR codes stay text, so leading zeros survive.
if (typeof value === 'number') {
return { label, kind: 'number', number: value };
}
return { label, kind: 'text', text: String(value) };
}
/** Drops the untouched rows of the legacy Interest Rate Detail grid. */
private buildInterestRateDetailRows(value: any): InterestRateDetailRow[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((row: any) =>
this.interestRateDetailColumns.some(
(column) =>
column.key !== 'interestType' &&
row?.[column.key] !== null &&
row?.[column.key] !== undefined &&
row?.[column.key] !== '' &&
row?.[column.key] !== false
)
);
}
private glAccountOptionsFor(kind: CustomGlAccountKind): GLAccount[] {
const options = this.loanProductsTemplate?.accountingMappingOptions;
switch (kind) {
case 'asset':
return options?.assetAccountOptions || [];
case 'income':
return options?.incomeAccountOptions || [];
case 'expense':
return options?.expenseAccountOptions || [];
case 'liability':
return options?.liabilityAccountOptions || [];
}
}
}
@@ -26,7 +26,6 @@
<div class="lp-tab-content">
<mifosx-loan-product-details-step
[loanProductsTemplate]="loanProductsTemplate"
(productSubGroupChange)="setProductSubGroup($event)"
></mifosx-loan-product-details-step>
</div>
</mat-tab>
@@ -52,7 +51,6 @@
<mifosx-loan-product-settings-step
[loanProductsTemplate]="loanProductsTemplate"
[isLinkedToFloatingInterestRates]="loanProductTermsForm.get('isLinkedToFloatingInterestRates')"
[productSubGroup]="selectedProductSubGroup"
(advancePaymentStrategy)="advancePaymentStrategy($event)"
></mifosx-loan-product-settings-step>
</div>
@@ -88,7 +88,6 @@ export class CreateLoanProductComponent implements OnInit {
selectedTabIndex = 0;
isAdvancedPaymentStrategy = false;
selectedProductSubGroup = '';
paymentAllocation: PaymentAllocation[] = [];
creditAllocation: PaymentAllocation[] = [];
supportedInterestRefundTypes: StringEnumOptionData[] = [];
@@ -145,10 +144,6 @@ export class CreateLoanProductComponent implements OnInit {
return this.loanProductTermsStep.loanProductTermsForm;
}
setProductSubGroup(value: string) {
this.selectedProductSubGroup = value;
}
advancePaymentStrategy(value: string) {
this.isAdvancedPaymentStrategy = LoanProducts.isAdvancedPaymentAllocationStrategy(value);
if (this.isAdvancedPaymentStrategy) {
@@ -249,53 +244,6 @@ export class CreateLoanProductComponent implements OnInit {
}
get loanProductFormValid() {
// TEMP DEBUG — remove once the invalid-step diagnosis is done.
const forms: [string, any][] = [
[
'details',
this.loanProductDetailsForm
],
[
'currency',
this.loanProductCurrencyForm
],
[
'terms',
this.loanProductTermsForm
],
[
'settings',
this.loanProductSettingsForm
],
[
'accounting',
this.loanProductAccountingForm
]
];
if (this.isAdvancedPaymentStrategy) {
forms.push([
'incomeCapitalization',
this.loanIncomeCapitalizationForm
]);
}
const invalid = forms.filter(
([
,
form
]) => !form?.valid
);
if (invalid.length) {
invalid.forEach(
([
name,
form
]) => {
const invalidControls = Object.keys(form.controls || {}).filter((key) => form.get(key)?.invalid);
console.warn(`[loanProductFormValid] "${name}" form invalid. Invalid controls:`, invalidControls);
}
);
}
if (this.isAdvancedPaymentStrategy) {
return (
this.loanProductDetailsForm.valid &&
@@ -39,7 +39,6 @@
<mifosx-loan-product-details-step
[loanProductsTemplate]="loanProductAndTemplate"
(productSubGroupChange)="setProductSubGroup($event)"
></mifosx-loan-product-details-step>
</mat-step>
@@ -57,7 +56,6 @@
<mifosx-loan-product-settings-step
[loanProductsTemplate]="loanProductAndTemplate"
[isLinkedToFloatingInterestRates]="loanProductTermsForm.get('isLinkedToFloatingInterestRates')"
[productSubGroup]="selectedProductSubGroup"
(advancePaymentStrategy)="advancePaymentStrategy($event)"
>
</mifosx-loan-product-settings-step>
@@ -95,7 +95,6 @@ export class EditLoanProductComponent implements OnInit {
itemsByDefault: GlobalConfiguration[] = [];
isAdvancedPaymentStrategy = false;
selectedProductSubGroup = '';
wasPaymentAllocationChanged = false;
paymentAllocation: PaymentAllocation[] = [];
creditAllocation: CreditAllocation[] = [];
@@ -117,9 +116,7 @@ export class EditLoanProductComponent implements OnInit {
const loanProducts = this.loanProducts;
this.route.data.subscribe((data: { loanProductAndTemplate: any; configurations: any }) => {
// Lift the saved legacy "Account Type Creation" fields to the top level before any step reads
// them, since the backend returns them nested under `cbsExtensions`.
this.loanProductAndTemplate = loanProducts.flattenCbsExtensions(data.loanProductAndTemplate);
this.loanProductAndTemplate = data.loanProductAndTemplate;
const assetAccountData = this.loanProductAndTemplate.accountingMappingOptions.assetAccountOptions || [];
const liabilityAccountData = this.loanProductAndTemplate.accountingMappingOptions.liabilityAccountOptions || [];
this.loanProductAndTemplate.accountingMappingOptions.assetAndLiabilityAccountOptions =
@@ -221,10 +218,6 @@ export class EditLoanProductComponent implements OnInit {
this.isAdvancedPaymentStrategy = LoanProducts.isAdvancedPaymentAllocationStrategy(value);
}
setProductSubGroup(value: string): void {
this.selectedProductSubGroup = value;
}
buildAdvancedPaymentAllocation(): void {
this.advancedPaymentAllocations = this.advancedPaymentStrategy.buildAdvancedPaymentAllocationList(
this.loanProductAndTemplate
@@ -1,820 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/**
* Metadata for the legacy "Account Type Creation" fields grafted onto the loan product form.
*
* These fields have no home in Fineract's loan product schema, so `LoanProducts.buildPayload()`
* strips them before submitting. They are still worth showing in full on the preview step, which is
* what this file exists for: the grouping and labels here mirror the create/edit form, so an
* officer reviews the same wording they typed against.
*
* Each section carries the `step` it was entered on, so the preview can show a section directly
* under that tab's own summary instead of collecting every legacy field into one block at the
* bottom -- reviewing "Cheque Book Settings" is only meaningful next to the rest of Settings.
*
* `CUSTOM_UI_ONLY_FIELDS` is derived from `CUSTOM_FIELD_SECTIONS` rather than kept alongside it, so
* the strip list and the preview can never disagree about which fields are custom.
*/
import { UntypedFormGroup } from '@angular/forms';
/** Which set of GL accounts a custom mapping field draws from, for resolving an id to an account. */
export type CustomGlAccountKind = 'asset' | 'income' | 'expense' | 'liability';
/** The create/edit tab a section's fields are entered on. */
export type CustomFieldStep = 'details' | 'settings' | 'terms' | 'charges' | 'accounting';
export interface CustomFieldSection {
/** Heading shown on the preview step. */
title: string;
/** Tab this section belongs under. */
step: CustomFieldStep;
fields: string[];
}
export const CUSTOM_FIELD_SECTIONS: CustomFieldSection[] = [
{
title: 'Classification & Legacy Header',
step: 'details',
fields: [
'productTypeGroup',
'productTypeSubGroup',
'debitIntCalculation',
'separatePenalInterest',
'creditIntCalculation',
'subAccountTypeBalance',
'finalIntRounding',
'roundingUpTo',
'accountTypeCode',
'micrTransactionCode',
'reservedEcsSerialNumber',
'reverseEcsNumber',
'lockChequeInfoEnabled',
'lockPageTwoInfoEnabled',
'computerizationDate',
'chequePrintDescription',
'posAccountTypeDescription',
'pfmsAccountType',
'renewalDateAddMonths',
'documentRenewalDateAddMonths',
'ppsLimit',
'isWriteOffAccountType',
'writeOffAccountTypeCode',
'renewalGraceDays'
]
},
{
title: 'Cheque Book Settings',
step: 'settings',
fields: [
'chequeBookIssueEnabled',
'chequeValidationRequired',
'isFirstChequeBookFree',
'chequePrintingAccountType',
'allowWithdrawalSlipUsage',
'maintainChequeBookStockRegistry',
'freeChequeBooksPerYear',
'allowCreditByChequeNumber',
'creditChequeValidationRequired',
'graceDaysForStockStatement'
]
},
{
title: 'Additional Product Settings',
step: 'settings',
fields: [
'accountTypeActiveForTransactions',
'subTypeActivityEnabled',
'accountOpeningCharges',
'maximumGoldLoanLimit',
'lastAccountNumberOpened',
'customerTerminationAllowed',
'chargesForCashCreditAboveLimit',
'deathInterestGLAccount',
'startingLedgerNumber',
'endingLedgerNumber',
'currentLedgerNumber',
'remittanceAccountNumber',
'isNonResidentIndianAccount',
'debitClearingGLAccount',
'missedCallAlertServiceActive',
'standingInstructionChargeGLAccount',
'standingInstructionDebitChargeSkipOption',
'openingBalanceMultiplierForRemittanceCharges',
'goldLoanPacketNumberWithYearEnabled',
'accountMoveToInoperativeEnabled',
'generateGlDiffEntryEnabled',
'subTypeWiseGlBalanceGenerationEnabled',
'validateBeforeDeletingDebitEntryEnabled',
'rcFileConceptEnabled',
'accountCloseZeroBalanceInOpenDayProcess',
'accountCloseZeroBalanceAtSubledgerTranEntry',
'limitEnhancementAgainstClearingScreenDisplayEnabled',
'advanceInterestParameterInLedgerEnabled',
'vehicleDetailInMasterEnabled',
'deletedChequeIssueEnabled',
'dpReduceInDocumentRenewalDateEnabled',
'dpReduceInRenewalDateEnabled',
'autoCloseAtDayEndEnabled',
'loanRateFromMasterEnabled',
'chequeShortName',
'statementPrintEnabled',
'neslCode'
]
},
{
title: 'Interest Related Parameters',
step: 'settings',
fields: [
'drawingPowerAndLimitGenerationProcedure',
'advancesInterestCalculationMethod',
'againstInterestCalculationEnabled',
'dayWiseAgainstClearingInterestRounding',
'interestCalculationOneFourthMethod',
'againstClearingInterestProcedureType',
'considerDebitClosingAmountForAgainstClearing',
'interestRoundingMethod',
'penaltyInterestCalculationEnabled',
'penaltyInterestCalculationMethod',
'penaltyInterestProcedureNumber',
'overdrawnInterestCalculationMethod',
'calculateOverdrawnInterestAfterRenewal',
'interestOnCreditBalance',
'penaltyInterestDateDifferentThanRegularInterest',
'fixedDepositOverdraftCertificateSortOrder',
'useDocumentSubmissionDateForInterest',
'loanManagementSystemRenewalPenaltyActive',
'minimumNormalInterestAmount',
'minimumInterestCalculationMethod',
'minimumAgainstClearingInterestAmount',
'limitIncreaseUptoInterestAmountInInterestCalculation',
'minimumPenaltyInterestAmount',
'daysForLimitIncreaseUptoInterestAmount',
'additionalInterestRateForFixedDepositOverdraft',
'considerDdPayorderInAgainstClearingInterestCalculation',
'graceDaysAfterRenewDate',
'applyUndebitedInterestConceptEnabled',
'interestRegisterPrintFormatType',
'normalGraceDaysAfterOverdue',
'allowAgainstClearingEntry',
'allowNoBalanceEntry',
'fixedDepositOverdraftPenaltyInterestCombinedWithNormalInterest',
'allowNoBalanceEntryWithPassword',
'undebitedSimpleInterestAccountWise',
'dailyInterestCalculationEnabled',
'dailyInterestCalculationStartDate',
'nonPerformingAssetPenalInterestRateUpdate',
'nonPerformingAssetPenalInterestRate',
'fixedDepositOverdraftPenalInterestRateUpdateInBranchProcess',
'addPenalInterestRate',
'atyp14SimpleInterestEnabled',
'fixedDepositOverdraftMarginType',
'checkMasterDetailEditedPassingPendingEnabled',
'creditDebitInterestCalculationSeparateEnabled',
'onTimeInterestProvisionEnabled',
'checkAgainstClearingEnabled',
'gstOnPenaltyEnabled',
'penaltyInterestGraceDays',
'overdrawnInterestRate',
'calculateDailyLimitEnhancementTodEnabled',
'fixedDepositOverdraftMultipleInterestRateEnabled',
'graceDaysAfterOverdueForMoratorium'
]
},
{
title: 'Credit Bureau Settings (CIBIL)',
step: 'settings',
fields: [
'cibilAccountType',
'cibilCommercialAccountType',
'highmarkAccountType',
'highmarkCommercialAccountType',
'experianAccountType',
'experianCommercialAccountType',
'equifaxAccountType',
'equifaxCommercialAccountType'
]
},
{
title: 'RBI Compliance & Product Category',
step: 'settings',
fields: [
'loanProductCategory',
'penalChargeMethod',
'isFloatingRate',
'foreclosureChargesApplicable',
'keyFactStatementEnabled',
'annualPercentageRateCalculationEnabled'
]
},
{
title: 'Channel & Transaction Limits',
step: 'settings',
fields: [
'isMobileAppEnabled',
'isAtmEnabled',
'atmYearlyChargeEnabled',
'atmMinimumBalance',
'emailActiveEnabled',
'isImpsEnabled',
'isUpiEnabled',
'isPosEnabled',
'isBbpsEnabled',
'interBankLimitPerDay',
'interBankLimitPerTransaction',
'neftRtgsLimitPerDay',
'neftRtgsLimitPerTransaction',
'neftCutoffLimit',
'rtgsCutoffLimit',
'neftRtgsMinimumBalance',
'isChargeRequiredNeftRtgsCbs',
'isChargeRequiredNeftRtgsMobile',
'impsLimitPerDay',
'impsLimitPerTransaction',
'impsLimitPerMonth',
'impsMinLimit',
'impsMonthlyTransactionCount'
]
},
{
title: 'POS Facility & ATM CSV Settings',
step: 'settings',
fields: [
'maxPerTransactionLimitAtm',
'maxDayTransactionLimitAtm',
'maxPerTransactionLimitPos',
'maxDayTransactionLimitPos',
'maxPerTransactionLimitEcom',
'maxDayTransactionLimitEcom',
'maxPerTransactionLimitUpi',
'maxDayTransactionLimitUpi',
'atmCsvProductCode',
'atmCsvTypeCode'
]
},
{
title: 'Auto Interest Post Parameters',
step: 'settings',
fields: [
'autoInterestPostType',
'autoInterestPostWhen',
'autoInterestPostFixDay',
'autoLastInterestDate'
]
},
{
title: 'Foreclosure / Early Account Closure',
step: 'settings',
fields: [
'calculateEarlyAccountCloseCharge',
'earlyAccountCloseCharge',
'earlyAccountClosePeriodInMonths'
]
},
{
title: 'Legacy Terms',
step: 'terms',
fields: [
'debitBalanceAllow',
'allowDebitTransAfterDueDate',
'branchLevelAccountType',
'interestRateDetail'
]
},
{
title: 'Legacy Cheque, Standing Instruction & Minimum Balance Charges',
step: 'charges',
fields: [
'chequeBookChargeEnabled',
'chequeReturnChargeEnabled',
'icChargeDebitEnabled',
'cashBundleChargeEnabled',
'cashBundleChargeWithPanLimit',
'perChequeEntryCharge',
'perChequeEntryServiceCharge',
'perInwardChequeEntryCharge',
'perOutwardChequeEntryCharge',
'creditorStandingInstructionFailCharge',
'debtorStandingInstructionFailCharge',
'minimumBalanceCheckingType',
'autoMinimumBalanceCharge',
'calculateDailyMinimumBalanceCharges',
'minimumBalanceChargeEnabled',
'minimumBalanceChargeAmount',
'minimumBalanceChargeForChequeIssue',
'minimumBalanceChargeMethod',
'monthsAfterMinBalanceChargeCalculate',
'forChequeIssue',
'forChequeIssueAvg',
'forWithdrawalSlip',
'forWithdrawalSlipAvg',
'calculateDailyAgainstClearingCharge'
]
},
{
title: 'Custom GL Account Mappings',
step: 'accounting',
fields: [
'intReceivablesGlAccountId',
'intPayableGlAccountId',
'deafIntPayableGlAccountId',
'intOverdueReserveAccountId',
'undebitedInterestAccountId',
'penalIntCrGlAccountId',
'rdMaturedGlAccountId',
'glAccountId',
'icChargeGlAccountId',
'inoperativeGlAccountId',
'creditBalanceLiabilityGlAccountId',
'creditBalanceAssetGlAccountId',
'overdueAccountsGlAccountId',
'rebateGlAccountId'
]
}
];
/** Form labels, copied from the create/edit step templates so preview wording matches. */
export const CUSTOM_FIELD_LABELS: Record<string, string> = {
productTypeGroup: 'Account Type Group',
productTypeSubGroup: 'Product Type Sub Group',
debitIntCalculation: 'Debit Int. Calculation',
separatePenalInterest: 'Separate Penal Int.',
creditIntCalculation: 'Credit Int. Calculation',
subAccountTypeBalance: 'Sub A/c Type Balance',
finalIntRounding: 'Final Int. Rounding',
roundingUpTo: 'Rounding UpTo',
accountTypeCode: 'A/c Type Code',
micrTransactionCode: 'MICR Trn. Code',
reservedEcsSerialNumber: 'Reserved Code Srno For ECS',
reverseEcsNumber: 'Reverse ECS No.',
lockChequeInfoEnabled: 'Lock Cheque Info',
lockPageTwoInfoEnabled: 'Lock Page 2 Info',
computerizationDate: 'Computerization Date',
chequePrintDescription: 'Chq. Print Description',
posAccountTypeDescription: 'POS A/c Type Description',
pfmsAccountType: 'PFMS Account Type',
renewalDateAddMonths: 'Renewal Dt. Add Months',
documentRenewalDateAddMonths: 'Doc. Renewal Dt. Add Months',
ppsLimit: 'PPS Limit',
isWriteOffAccountType: 'Is this Actyp Write Off?',
writeOffAccountTypeCode: 'Write Off Actyp',
renewalGraceDays: 'Renewal Grace Days',
chequeBookIssueEnabled: 'Cheque Book Issue Enabled',
chequeValidationRequired: 'Cheque Validation Required',
isFirstChequeBookFree: 'Is First Cheque Book Free',
chequePrintingAccountType: 'Cheque Printing Account Type',
allowWithdrawalSlipUsage: 'Allow Withdrawal Slip Usage',
maintainChequeBookStockRegistry: 'Maintain Cheque Book Stock Registry',
freeChequeBooksPerYear: 'Free Cheque Books Per Year',
allowCreditByChequeNumber: 'Allow Credit By Cheque Number',
creditChequeValidationRequired: 'Credit Cheque Validation Required',
graceDaysForStockStatement: 'Grace Days For Stock Statement',
accountTypeActiveForTransactions: 'Account Type Active For Transactions',
subTypeActivityEnabled: 'Sub Type Activity Enabled',
accountOpeningCharges: 'Account Opening Charges',
maximumGoldLoanLimit: 'Maximum Gold Loan Limit',
lastAccountNumberOpened: 'Last Account Number Opened',
customerTerminationAllowed: 'Customer Termination Allowed',
chargesForCashCreditAboveLimit: 'Charges For Cash Credit Above Limit',
deathInterestGLAccount: 'Death Interest GL Account',
startingLedgerNumber: 'Starting Ledger Number',
endingLedgerNumber: 'Ending Ledger Number',
currentLedgerNumber: 'Current Ledger Number',
remittanceAccountNumber: 'Remittance Account Number',
isNonResidentIndianAccount: 'Is Non Resident Indian Account',
debitClearingGLAccount: 'Debit Clearing GL Account',
missedCallAlertServiceActive: 'Missed Call Alert Service Active',
standingInstructionChargeGLAccount: 'Standing Instruction Charge GL Account',
standingInstructionDebitChargeSkipOption: 'Standing Instruction Debit Charge Skip Option',
openingBalanceMultiplierForRemittanceCharges: 'Opening Balance Multiplier For Remittance Charges',
goldLoanPacketNumberWithYearEnabled: 'Gold Loan [Packet No.] With Year?',
accountMoveToInoperativeEnabled: 'A/c Move To InOperative A/c',
generateGlDiffEntryEnabled: 'Generate GL Diff Entry',
subTypeWiseGlBalanceGenerationEnabled: 'Sub Type Wise GL Balance Gen',
validateBeforeDeletingDebitEntryEnabled: 'Want To Validation While Deleting Debit Entry',
rcFileConceptEnabled: 'Want To RC File Concept',
accountCloseZeroBalanceInOpenDayProcess: 'A/c Close With Zero Bal. In Open Day Process',
accountCloseZeroBalanceAtSubledgerTranEntry: 'A/c Close When Bal. Zero At Subled Tran Entry',
limitEnhancementAgainstClearingScreenDisplayEnabled: 'Lmt.Enh/Ag.Clg Screen Display',
advanceInterestParameterInLedgerEnabled: 'Advance Int. Param In Ledger',
vehicleDetailInMasterEnabled: 'Vehicle Detail In Master',
deletedChequeIssueEnabled: 'Deleted Chq. Issue',
dpReduceInDocumentRenewalDateEnabled: 'DP Reduce In Doc. Renewal Date',
dpReduceInRenewalDateEnabled: 'DP Reduce In Renewal Date',
autoCloseAtDayEndEnabled: 'Auto Close Day-End',
loanRateFromMasterEnabled: 'Loan Rate From Master',
chequeShortName: 'Cheque Short Name',
statementPrintEnabled: 'Statement Print',
neslCode: 'NESL Code',
drawingPowerAndLimitGenerationProcedure: 'Drawing Power And Limit Generation Procedure',
advancesInterestCalculationMethod: 'Advances Interest Calculation Method',
againstInterestCalculationEnabled: 'Against Interest Calculation Enabled',
dayWiseAgainstClearingInterestRounding: 'Day Wise Against Clearing Interest Rounding',
interestCalculationOneFourthMethod: 'Interest Calculation One Fourth Method',
againstClearingInterestProcedureType: 'Against Clearing Interest Procedure Type',
considerDebitClosingAmountForAgainstClearing: 'Consider Debit Closing Amount For Against Clearing',
interestRoundingMethod: 'Interest Rounding Method',
penaltyInterestCalculationEnabled: 'Penalty Interest Calculation Enabled',
penaltyInterestCalculationMethod: 'Penalty Interest Calculation Method',
penaltyInterestProcedureNumber: 'Penalty Interest Procedure Number',
overdrawnInterestCalculationMethod: 'Overdrawn Interest Calculation Method',
calculateOverdrawnInterestAfterRenewal: 'Calculate Overdrawn Interest After Renewal',
interestOnCreditBalance: 'Interest On Credit Balance',
penaltyInterestDateDifferentThanRegularInterest: 'Penalty Interest Date Different Than Regular Interest',
fixedDepositOverdraftCertificateSortOrder: 'Fixed Deposit Overdraft Certificate Sort Order',
useDocumentSubmissionDateForInterest: 'Use Document Submission Date For Interest',
loanManagementSystemRenewalPenaltyActive: 'Loan Management System Renewal Penalty Active',
minimumNormalInterestAmount: 'Minimum Normal Interest Amount',
minimumInterestCalculationMethod: 'Minimum Interest Calculation Method',
minimumAgainstClearingInterestAmount: 'Minimum Against Clearing Interest Amount',
limitIncreaseUptoInterestAmountInInterestCalculation: 'Limit Increase Upto Interest Amount In Interest Calculation',
minimumPenaltyInterestAmount: 'Minimum Penalty Interest Amount',
daysForLimitIncreaseUptoInterestAmount: 'Days For Limit Increase Upto Interest Amount',
additionalInterestRateForFixedDepositOverdraft: 'Additional Interest Rate For Fixed Deposit Overdraft',
considerDdPayorderInAgainstClearingInterestCalculation:
'Consider DD/Payorder In Against Clearing Interest Calculation',
graceDaysAfterRenewDate: 'Grace Days After Renewal Date',
applyUndebitedInterestConceptEnabled: 'Apply Undebited Interest Concept Enabled',
interestRegisterPrintFormatType: 'Interest Register Print Format Type',
normalGraceDaysAfterOverdue: 'Normal Grace Days After Overdue',
allowAgainstClearingEntry: 'Allow Against Clearing Entry',
allowNoBalanceEntry: 'Allow No Balance Entry',
fixedDepositOverdraftPenaltyInterestCombinedWithNormalInterest:
'Fixed Deposit Overdraft Penalty Interest Combined With Normal Interest',
allowNoBalanceEntryWithPassword: 'Allow No Balance Entry With Password',
undebitedSimpleInterestAccountWise: 'Undebited Simple Interest Account Wise',
dailyInterestCalculationEnabled: 'Daily Interest Calculation Enabled',
dailyInterestCalculationStartDate: 'Daily Interest Calculation Start Date',
nonPerformingAssetPenalInterestRateUpdate: 'Non Performing Asset Penal Interest Rate Update',
nonPerformingAssetPenalInterestRate: 'Non Performing Asset Penal Interest Rate',
fixedDepositOverdraftPenalInterestRateUpdateInBranchProcess:
'Fixed Deposit Overdraft Penal Interest Rate Update In Branch Process',
addPenalInterestRate: 'Add Penal Interest Rate',
atyp14SimpleInterestEnabled: 'Atyp14 Simple Int.',
fixedDepositOverdraftMarginType: 'FDOD Margin Type',
checkMasterDetailEditedPassingPendingEnabled: 'Check Master Detail Edited (Passing Pend.)',
creditDebitInterestCalculationSeparateEnabled: 'Credit/Debit Int. Calc. Separate',
onTimeInterestProvisionEnabled: 'On Time Interest Provision',
checkAgainstClearingEnabled: 'Check Against Clearing',
gstOnPenaltyEnabled: 'GST On Penalty',
penaltyInterestGraceDays: 'Penalty Int. Grace Days',
overdrawnInterestRate: 'Over Drawn Int. Rate',
calculateDailyLimitEnhancementTodEnabled: 'Calculate Daily Limit Enhancement (TOD)',
fixedDepositOverdraftMultipleInterestRateEnabled: 'FDOD Multiple Int. Rate',
graceDaysAfterOverdueForMoratorium: 'Grace Days After Overdue For Moratorium A/c',
cibilAccountType: 'CIBIL Account Type',
cibilCommercialAccountType: 'CIBIL Commercial Account Type',
highmarkAccountType: 'Highmark Account Type',
highmarkCommercialAccountType: 'Highmark Commercial Account Type',
experianAccountType: 'Experian Account Type',
experianCommercialAccountType: 'Experian Commercial Account Type',
equifaxAccountType: 'Equifax Account Type',
equifaxCommercialAccountType: 'Equifax Commercial Account Type',
loanProductCategory: 'Loan Product Category (RBI Reporting)',
penalChargeMethod: 'Penal Charge Method',
isFloatingRate: 'Penal Charge Method',
foreclosureChargesApplicable: 'Foreclosure Charges Applicable',
keyFactStatementEnabled: 'Foreclosure Charges Applicable',
annualPercentageRateCalculationEnabled: 'Foreclosure Charges Applicable',
isMobileAppEnabled: 'Is Mobile App?',
isAtmEnabled: 'Is ATM?',
atmYearlyChargeEnabled: 'ATM Yearly Charge?',
atmMinimumBalance: 'ATM Min. Balance',
emailActiveEnabled: 'Email Active?',
isImpsEnabled: 'Is IMPS?',
isUpiEnabled: 'Is UPI?',
isPosEnabled: 'Is POS?',
isBbpsEnabled: 'Is BBPS?',
interBankLimitPerDay: 'Inter Bank Limit (Per Day)',
interBankLimitPerTransaction: 'Inter Bank Limit (Per Transaction)',
neftRtgsLimitPerDay: 'NEFT/RTGS Limit (Per Day)',
neftRtgsLimitPerTransaction: 'NEFT/RTGS Limit (Per Transaction)',
neftCutoffLimit: 'NEFT Cutoff Limit',
rtgsCutoffLimit: 'RTGS Cutoff Limit',
neftRtgsMinimumBalance: 'NEFT/RTGS Min. Balance',
isChargeRequiredNeftRtgsCbs: 'Is Charge Req. NEFT/RTGS (CBS)?',
isChargeRequiredNeftRtgsMobile: 'Is Charge Req. NEFT/RTGS (Mobile)?',
impsLimitPerDay: 'IMPS Limit (Per Day)',
impsLimitPerTransaction: 'IMPS Limit (Per Transaction)',
impsLimitPerMonth: 'IMPS Limit (Per Month)',
impsMinLimit: 'IMPS Min. Limit',
impsMonthlyTransactionCount: 'IMPS Monthly Transaction Count',
maxPerTransactionLimitAtm: 'Max Per Transaction Limit (ATM)',
maxDayTransactionLimitAtm: 'Max Day Transaction Limit (ATM)',
maxPerTransactionLimitPos: 'Max Per Transaction Limit (POS)',
maxDayTransactionLimitPos: 'Max Day Transaction Limit (POS)',
maxPerTransactionLimitEcom: 'Max Per Transaction Limit (ECOM)',
maxDayTransactionLimitEcom: 'Max Day Transaction Limit (ECOM)',
maxPerTransactionLimitUpi: 'Max Per Transaction Limit (UPI)',
maxDayTransactionLimitUpi: 'Max Day Transaction Limit (UPI)',
atmCsvProductCode: 'ATM CSV Product Code',
atmCsvTypeCode: 'ATM CSV Type Code',
autoInterestPostType: 'Auto Int. Post Type',
autoInterestPostWhen: 'Auto Int. Post When',
autoInterestPostFixDay: 'Fix Day',
autoLastInterestDate: 'Auto Last Int. Date',
calculateEarlyAccountCloseCharge: 'Calculate Early A/c Close Charge?',
earlyAccountCloseCharge: 'Early A/c Close Charge',
earlyAccountClosePeriodInMonths: 'Early A/c Close Period (In Months)',
debitBalanceAllow: 'Debit Balance Allow',
allowDebitTransAfterDueDate: 'Allow Debit Trans After Due Date',
branchLevelAccountType: 'Branch Level A/c Type',
interestRateDetail: 'Interest Rate Detail',
chequeBookChargeEnabled: 'Chq. Book Charge',
chequeReturnChargeEnabled: 'Chq. Return Charge',
icChargeDebitEnabled: 'I.C. Charge Debit',
cashBundleChargeEnabled: 'Cash Bundle Charge',
cashBundleChargeWithPanLimit: 'Cash Bundle Charge With PAN Limit',
perChequeEntryCharge: 'Per Cheque Entry Charge',
perChequeEntryServiceCharge: 'Per Cheque Entry Service Charge',
perInwardChequeEntryCharge: 'Per Inward Cheque Entry Charge',
perOutwardChequeEntryCharge: 'Per Outward Cheque Entry Charge',
creditorStandingInstructionFailCharge: 'Creditor Standing Instr. Fail Charge',
debtorStandingInstructionFailCharge: 'Debtor Standing Instr. Fail Charge',
minimumBalanceCheckingType: 'Minimum Balance Checking',
autoMinimumBalanceCharge: 'Auto Minimum Balance Charge',
calculateDailyMinimumBalanceCharges: 'Calculate Daily Min. Balance Charges',
minimumBalanceChargeEnabled: 'Minimum Balance Charge',
minimumBalanceChargeAmount: 'Min. Balance Charge (Rs.)',
minimumBalanceChargeForChequeIssue: 'Min. Balance Charge For Cheque Issue (Rs.)',
minimumBalanceChargeMethod: 'Min. Balance Charge Method',
monthsAfterMinBalanceChargeCalculate: 'Months After Min. Balance Charge Calculate',
forChequeIssue: 'For Cheque Issue',
forChequeIssueAvg: 'For Cheque Issue (Avg.)',
forWithdrawalSlip: 'For Withdrawal Slip',
forWithdrawalSlipAvg: 'For Withdrawal Slip (Avg.)',
calculateDailyAgainstClearingCharge: 'Calculate Daily Against Clearing Charge',
intReceivablesGlAccountId: 'Int. Receivables GL A/c',
intPayableGlAccountId: 'Int. Payable GL A/c',
deafIntPayableGlAccountId: 'Deaf Int. Payble GL A/c',
intOverdueReserveAccountId: 'Int. Overdue Reserve A/c (AS)',
undebitedInterestAccountId: 'UnDebited Interest A/c (LI)',
penalIntCrGlAccountId: 'Penal Int. Cr. GL A/c',
rdMaturedGlAccountId: 'RD Matured GL A/c',
glAccountId: 'GL A/c No.',
icChargeGlAccountId: 'I.C. Charge GL A/c No.',
inoperativeGlAccountId: 'InOperative GL A/c',
creditBalanceLiabilityGlAccountId: 'Cr.Balance Liability GL A/c',
creditBalanceAssetGlAccountId: 'Cr.Balance Asset GL A/c',
overdueAccountsGlAccountId: 'Overdue Accounts GL A/c No.',
rebateGlAccountId: 'Rebate GL A/c No.'
};
/**
* Custom GL mapping fields hold an account id, so the preview resolves each to its account rather
* than printing a bare number. The kind picks which option list from the product template to
* search -- it matches the `options` the Accounting step's own field config uses.
*/
export const CUSTOM_GL_ACCOUNT_FIELDS: Record<string, CustomGlAccountKind> = {
intReceivablesGlAccountId: 'asset',
intPayableGlAccountId: 'liability',
deafIntPayableGlAccountId: 'asset',
intOverdueReserveAccountId: 'asset',
undebitedInterestAccountId: 'liability',
penalIntCrGlAccountId: 'income',
rdMaturedGlAccountId: 'asset',
glAccountId: 'asset',
icChargeGlAccountId: 'income',
inoperativeGlAccountId: 'asset',
creditBalanceLiabilityGlAccountId: 'liability',
creditBalanceAssetGlAccountId: 'asset',
overdueAccountsGlAccountId: 'asset',
rebateGlAccountId: 'expense'
};
/** Columns of the legacy Interest Rate Detail grid, whose value is a row array rather than a scalar. */
export const INTEREST_RATE_DETAIL_COLUMNS: { key: string; label: string }[] = [
{
key: 'interestType',
label: 'Interest Type'
},
{
key: 'calculation',
label: 'Calculation'
},
{
key: 'normalMinRate',
label: 'Normal Min. Rate'
},
{
key: 'normalMaxRate',
label: 'Normal Max. Rate'
},
{
key: 'specialMinRate',
label: 'Special Min. Rate'
},
{
key: 'specialMaxRate',
label: 'Special Max. Rate'
},
{
key: 'period',
label: 'Period'
}
];
/**
* Legacy fields whose form control is a dropdown holding the *strings* 'YES' and 'NO', not a boolean.
*
* They are stored as BOOLEAN, so a saved product comes back from the API as true/false. Patching that
* straight into the form would match no option and leave the dropdown blank, which is why
* `LoanProducts.flattenCbsExtensions()` maps them back to 'YES'/'NO' on the way in.
*/
export const CUSTOM_YES_NO_STRING_FIELDS: string[] = [
'lockChequeInfoEnabled',
'lockPageTwoInfoEnabled',
'isWriteOffAccountType',
'chequeBookIssueEnabled',
'chequeValidationRequired',
'isFirstChequeBookFree',
'allowWithdrawalSlipUsage',
'maintainChequeBookStockRegistry',
'allowCreditByChequeNumber',
'creditChequeValidationRequired',
'accountTypeActiveForTransactions',
'subTypeActivityEnabled',
'customerTerminationAllowed',
'isNonResidentIndianAccount',
'missedCallAlertServiceActive',
'goldLoanPacketNumberWithYearEnabled',
'accountMoveToInoperativeEnabled',
'generateGlDiffEntryEnabled',
'subTypeWiseGlBalanceGenerationEnabled',
'validateBeforeDeletingDebitEntryEnabled',
'rcFileConceptEnabled',
'accountCloseZeroBalanceInOpenDayProcess',
'accountCloseZeroBalanceAtSubledgerTranEntry',
'limitEnhancementAgainstClearingScreenDisplayEnabled',
'advanceInterestParameterInLedgerEnabled',
'vehicleDetailInMasterEnabled',
'deletedChequeIssueEnabled',
'dpReduceInDocumentRenewalDateEnabled',
'dpReduceInRenewalDateEnabled',
'autoCloseAtDayEndEnabled',
'loanRateFromMasterEnabled',
'statementPrintEnabled',
'againstInterestCalculationEnabled',
'dayWiseAgainstClearingInterestRounding',
'considerDebitClosingAmountForAgainstClearing',
'penaltyInterestCalculationEnabled',
'calculateOverdrawnInterestAfterRenewal',
'interestOnCreditBalance',
'penaltyInterestDateDifferentThanRegularInterest',
'useDocumentSubmissionDateForInterest',
'loanManagementSystemRenewalPenaltyActive',
'limitIncreaseUptoInterestAmountInInterestCalculation',
'considerDdPayorderInAgainstClearingInterestCalculation',
'applyUndebitedInterestConceptEnabled',
'allowAgainstClearingEntry',
'allowNoBalanceEntry',
'fixedDepositOverdraftPenaltyInterestCombinedWithNormalInterest',
'allowNoBalanceEntryWithPassword',
'undebitedSimpleInterestAccountWise',
'dailyInterestCalculationEnabled',
'nonPerformingAssetPenalInterestRateUpdate',
'fixedDepositOverdraftPenalInterestRateUpdateInBranchProcess',
'atyp14SimpleInterestEnabled',
'checkMasterDetailEditedPassingPendingEnabled',
'creditDebitInterestCalculationSeparateEnabled',
'onTimeInterestProvisionEnabled',
'checkAgainstClearingEnabled',
'gstOnPenaltyEnabled',
'calculateDailyLimitEnhancementTodEnabled',
'fixedDepositOverdraftMultipleInterestRateEnabled',
'foreclosureChargesApplicable',
'isMobileAppEnabled',
'isAtmEnabled',
'atmYearlyChargeEnabled',
'emailActiveEnabled',
'isImpsEnabled',
'isUpiEnabled',
'isPosEnabled',
'isBbpsEnabled',
'isChargeRequiredNeftRtgsCbs',
'isChargeRequiredNeftRtgsMobile',
'calculateEarlyAccountCloseCharge',
'debitBalanceAllow',
'allowDebitTransAfterDueDate',
'branchLevelAccountType',
'chequeBookChargeEnabled',
'chequeReturnChargeEnabled',
'icChargeDebitEnabled',
'cashBundleChargeEnabled',
'autoMinimumBalanceCharge',
'calculateDailyMinimumBalanceCharges',
'minimumBalanceChargeEnabled',
'calculateDailyAgainstClearingCharge'
];
/**
* Sub-groups whose products are current-account-linked and therefore carry channel limits.
* Kept here rather than only on the Settings step so the preview can apply the same rule.
*/
export const CHANNEL_ENABLED_SUB_GROUPS: string[] = [
'cc',
'overdraft'
];
/** Sub-groups that are fixed-tenure EMI term loans, the only ones foreclosure applies to. */
export const TERM_LOAN_SUB_GROUPS: string[] = [
'term',
'personal',
'housing',
'vehicle',
'education',
'business',
'gold_emi'
];
/** Sub-groups issued against pledged gold, which have no cheque book. */
export const NO_CHEQUE_BOOK_SUB_GROUPS: string[] = [
'gold',
'gold_emi'
];
/**
* Whether a legacy section applies to a product, given its sub-group.
*
* The Settings step hides three of these sections outright for some sub-groups -- a Gold Loan has no
* cheque book, no channel limits and no foreclosure charges. The preview was still listing them,
* because the controls keep their defaults whether or not the section was ever rendered, so a Gold
* Loan showed a "Cheque Book Settings" table the officer never saw. Titles are matched against
* CUSTOM_FIELD_SECTIONS, so a rename has to happen in one place.
*
* An unknown or empty sub-group shows everything, matching how the form behaves before a sub-group
* is picked.
*/
export function isCustomSectionApplicable(sectionTitle: string, productSubGroup: string | null | undefined): boolean {
if (!productSubGroup) {
return true;
}
switch (sectionTitle) {
case 'Cheque Book Settings':
return !NO_CHEQUE_BOOK_SUB_GROUPS.includes(productSubGroup);
case 'Channel & Transaction Limits':
case 'POS Facility & ATM CSV Settings':
return CHANNEL_ENABLED_SUB_GROUPS.includes(productSubGroup);
case 'Foreclosure / Early Account Closure':
return TERM_LOAN_SUB_GROUPS.includes(productSubGroup);
default:
return true;
}
}
/** Legacy date fields. Fineract returns a date as [year, month, day], which needs a real Date for the picker. */
export const CUSTOM_DATE_FIELDS: string[] = [
'computerizationDate'
];
/** Every custom field, in form order. Derived from the sections above -- do not maintain separately. */
export const CUSTOM_UI_ONLY_FIELDS: string[] = CUSTOM_FIELD_SECTIONS.flatMap((section) => section.fields);
/**
* Copies a saved product's legacy field values into one step's form.
*
* Every step calls this with its own form and the same product: a field belonging to another step
* has no control here, so it is skipped. That works because the form control names and the field
* names in this file are the same strings -- which is also where the labels and sections above were
* read from -- so there is no per-step mapping to keep in step with the forms.
*
* Written as a loop rather than 220 lines of patchValue because a field added to a step's form later
* is picked up here for free, and a typo cannot silently leave one field unpopulated.
*
* Only real values are applied: a null or absent field is left at whatever default the form set, so
* this can run after a step's own patchValue without blanking anything it deliberately defaulted.
*
* Patched twice, deliberately. Some of these controls drive others through a valueChanges
* subscription -- picking a Product Category auto-fills the four credit bureau account types, and
* the Details sub-group decides which Settings sections are shown. The first pass lets those
* subscriptions run so that dependent state is set up; the second pass repeats the same values with
* events suppressed, so a value the officer actually saved wins over whatever an auto-fill just
* wrote over it. Without the second pass, reopening a Personal Loan product silently reset its
* saved CIBIL account type back to the category default.
*/
export function patchCustomFields(form: UntypedFormGroup, product: any): void {
if (!form || !product) {
return;
}
const apply = (emitEvent: boolean) => {
for (const field of CUSTOM_UI_ONLY_FIELDS) {
const control = form.get(field);
if (!control) {
continue;
}
const value = product[field];
if (value === undefined || value === null) {
continue;
}
control.patchValue(value, { emitEvent });
}
};
apply(true);
apply(false);
}
@@ -8,7 +8,6 @@
import { Component, Input, OnChanges, OnInit, SimpleChanges, inject } from '@angular/core';
import { UntypedFormArray, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms';
import { patchCustomFields } from '../../loan-product-custom-fields';
import { MatDialog } from '@angular/material/dialog';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
@@ -211,14 +210,7 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
intOverdueReserveAccountId: accountingMappings.intOverdueReserveAccount?.id || '',
undebitedInterestAccountId: accountingMappings.undebitedInterestAccount?.id || '',
penalIntCrGlAccountId: accountingMappings.penalIntCrGlAccount?.id || '',
rdMaturedGlAccountId: accountingMappings.rdMaturedGlAccount?.id || '',
glAccountId: accountingMappings.glAccount?.id || '',
icChargeGlAccountId: accountingMappings.icChargeGlAccount?.id || '',
inoperativeGlAccountId: accountingMappings.inoperativeGlAccount?.id || '',
creditBalanceLiabilityGlAccountId: accountingMappings.creditBalanceLiabilityGlAccount?.id || '',
creditBalanceAssetGlAccountId: accountingMappings.creditBalanceAssetGlAccount?.id || '',
overdueAccountsGlAccountId: accountingMappings.overdueAccountsGlAccount?.id || '',
rebateGlAccountId: accountingMappings.rebateGlAccount?.id || ''
rdMaturedGlAccountId: accountingMappings.rdMaturedGlAccount?.id || ''
});
if (this.loanProductsTemplate.paymentChannelToFundSourceMappings) {
@@ -242,10 +234,6 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
this.setOtherGlMappings('incomeOtherGlMappings', glMappings.income || [], this.incomeAccountData);
}
}
// Repopulate the legacy "Account Type Creation" fields saved against this product.
// Last, so a value actually stored wins over any default set above.
patchCustomFields(this.loanProductAccountingForm, this.loanProductsTemplate);
}
public createLoanProductAccountingForm() {
@@ -288,52 +276,20 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
'incomeFromGoodwillCreditInterestAccountId',
'incomeFromGoodwillCreditFeesAccountId',
'incomeFromGoodwillCreditPenaltyAccountId',
'intReceivablesGlAccountId',
'intPayableGlAccountId',
'glAccountId',
'inoperativeGlAccountId'
];
commonFields.forEach((field) => {
if (!this.loanProductAccountingForm.contains(field)) {
this.loanProductAccountingForm.addControl(field, new UntypedFormControl('', Validators.required));
}
});
// Accrual-only receivable fields: their input (mifosx-gl-account-selector) only renders
// when isAccrualAccounting() is true, so only require them in that case — otherwise they'd
// be unconditionally required with no visible field to fill them, permanently invalidating
// the form (accountingRule is hard-locked to Cash in this build).
const accrualOnlyFields = [
'receivableInterestAccountId',
'receivableFeeAccountId',
'receivablePenaltyAccountId'
];
accrualOnlyFields.forEach((field) => {
if (!this.loanProductAccountingForm.contains(field)) {
this.loanProductAccountingForm.addControl(
field,
new UntypedFormControl('', this.isAccrualAccounting() ? Validators.required : null)
);
}
});
// Optional custom GL account fields (legacy screenshot fields not marked mandatory) —
// no Validators.required, so mat-form-field does not render a required asterisk for these.
const optionalCommonFields = [
'receivablePenaltyAccountId',
'intReceivablesGlAccountId',
'intPayableGlAccountId',
'deafIntPayableGlAccountId',
'intOverdueReserveAccountId',
'undebitedInterestAccountId',
'penalIntCrGlAccountId',
'rdMaturedGlAccountId',
'icChargeGlAccountId',
'creditBalanceLiabilityGlAccountId',
'creditBalanceAssetGlAccountId',
'overdueAccountsGlAccountId',
'rebateGlAccountId'
'rdMaturedGlAccountId'
];
optionalCommonFields.forEach((field) => {
commonFields.forEach((field) => {
if (!this.loanProductAccountingForm.contains(field)) {
this.loanProductAccountingForm.addControl(field, new UntypedFormControl(''));
this.loanProductAccountingForm.addControl(field, new UntypedFormControl('', Validators.required));
}
});
@@ -664,7 +620,7 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
}
public isAccrualAccounting(): boolean {
const accountingRule = this.loanProductAccountingForm.get('accountingRule').value;
const accountingRule = this.loanProductAccountingForm.value.accountingRule;
return accountingRule === 3 || accountingRule === 4;
}
@@ -752,15 +708,15 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
{
key: 'intReceivablesGlAccountId',
label: 'Int. Receivables GL A/c',
inputLabel: 'Int Receivables GL A/c',
inputLabel: 'Int. Receivables GL A/c',
controlName: 'intReceivablesGlAccountId',
options: this.assetAccountData,
required: true
required: false
},
{
key: 'deafIntPayableGlAccountId',
label: 'Deaf Int. Payble GL A/c',
inputLabel: 'Deaf Int Payble GL A/c',
inputLabel: 'Deaf Int. Payble GL A/c',
controlName: 'deafIntPayableGlAccountId',
options: this.assetAccountData,
required: false
@@ -768,7 +724,7 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
{
key: 'intOverdueReserveAccountId',
label: 'Int. Overdue Reserve A/c (AS)',
inputLabel: 'Int Overdue Reserve A/c',
inputLabel: 'Int. Overdue Reserve A/c',
controlName: 'intOverdueReserveAccountId',
options: this.assetAccountData,
required: false
@@ -780,38 +736,6 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
controlName: 'rdMaturedGlAccountId',
options: this.assetAccountData,
required: false
},
{
key: 'glAccountId',
label: 'GL A/c No.',
inputLabel: 'GL A/c No',
controlName: 'glAccountId',
options: this.assetAccountData,
required: true
},
{
key: 'inoperativeGlAccountId',
label: 'InOperative GL A/c',
inputLabel: 'InOperative GL A/c',
controlName: 'inoperativeGlAccountId',
options: this.assetAccountData,
required: true
},
{
key: 'creditBalanceAssetGlAccountId',
label: 'Cr.Balance Asset GL A/c',
inputLabel: 'Cr Balance Asset GL A/c',
controlName: 'creditBalanceAssetGlAccountId',
options: this.assetAccountData,
required: false
},
{
key: 'overdueAccountsGlAccountId',
label: 'Overdue Accounts GL A/c No.',
inputLabel: 'Overdue Accounts GL A/c No',
controlName: 'overdueAccountsGlAccountId',
options: this.assetAccountData,
required: false
}
],
otherMappingsControlName: 'assetsOtherGlMappings',
@@ -924,18 +848,10 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
{
key: 'penalIntCrGlAccountId',
label: 'Penal Int. Cr. GL A/c',
inputLabel: 'Penal Int Cr GL A/c',
inputLabel: 'Penal Int. Cr. GL A/c',
controlName: 'penalIntCrGlAccountId',
options: this.incomeAccountData,
required: false
},
{
key: 'icChargeGlAccountId',
label: 'I.C. Charge GL A/c No.',
inputLabel: 'IC Charge GL A/c No',
controlName: 'icChargeGlAccountId',
options: this.incomeAccountData,
required: false
}
],
otherMappingsControlName: 'incomeOtherGlMappings',
@@ -987,14 +903,6 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
options: this.expenseAccountData,
required: true,
deferredBuyDownMerchantOnly: true
},
{
key: 'rebateGlAccountId',
label: 'Rebate GL A/c No.',
inputLabel: 'Rebate GL A/c No',
controlName: 'rebateGlAccountId',
options: this.expenseAccountData,
required: false
}
],
otherMappingsControlName: 'expensesOtherGlMappings',
@@ -1026,10 +934,10 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
{
key: 'intPayableGlAccountId',
label: 'Int. Payable GL A/c',
inputLabel: 'Int Payable GL A/c',
inputLabel: 'Int. Payable GL A/c',
controlName: 'intPayableGlAccountId',
options: this.liabilityAccountData,
required: true
required: false
},
{
key: 'undebitedInterestAccountId',
@@ -1038,14 +946,6 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
controlName: 'undebitedInterestAccountId',
options: this.liabilityAccountData,
required: false
},
{
key: 'creditBalanceLiabilityGlAccountId',
label: 'Cr.Balance Liability GL A/c',
inputLabel: 'Cr Balance Liability GL A/c',
controlName: 'creditBalanceLiabilityGlAccountId',
options: this.liabilityAccountData,
required: false
}
],
otherMappingsControlName: 'liabilitiesOtherGlMappings',
@@ -6,11 +6,7 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<!--
Charges & Overdue Charges (native Fineract charge-picker sections) are temporarily
disabled — not currently in use (no matching backend charge data / not part of the
active workflow). Re-enable by removing this comment block when needed.
<!-- Charges -->
<div class="lpch-section">
<div class="lpch-section__header">
<i class="fas fa-receipt"></i>
@@ -93,6 +89,7 @@
</div>
</div>
<!-- Overdue Charges -->
<div class="lpch-section">
<div class="lpch-section__header">
<i class="fas fa-exclamation-circle"></i>
@@ -174,174 +171,3 @@
}
</div>
</div>
-->
<!-- Additional Charges (legacy Page 2/Page 4 charge fields) -->
<form [formGroup]="legacyChargesForm">
<div class="lpch-section">
<div class="lpch-section__header">
<i class="fas fa-money-check"></i>
<h3>Cheque &amp; Standing Instruction Charges</h3>
</div>
<div class="lpch-section__body lpch-grid-4">
<mat-form-field appearance="outline">
<mat-label>Chq. Book Charge</mat-label>
<mat-select formControlName="chequeBookChargeEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Chq. Return Charge</mat-label>
<mat-select formControlName="chequeReturnChargeEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>I.C. Charge Debit</mat-label>
<mat-select formControlName="icChargeDebitEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Cash Bundle Charge</mat-label>
<mat-select formControlName="cashBundleChargeEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Cash Bundle Charge With PAN Limit</mat-label>
<input matInput type="number" formControlName="cashBundleChargeWithPanLimit" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Per Cheque Entry Charge</mat-label>
<input matInput type="number" formControlName="perChequeEntryCharge" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Per Cheque Entry Service Charge</mat-label>
<input matInput type="number" formControlName="perChequeEntryServiceCharge" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Per Inward Cheque Entry Charge</mat-label>
<input matInput type="number" formControlName="perInwardChequeEntryCharge" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Per Outward Cheque Entry Charge</mat-label>
<input matInput type="number" formControlName="perOutwardChequeEntryCharge" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Creditor Standing Instr. Fail Charge</mat-label>
<input matInput type="number" formControlName="creditorStandingInstructionFailCharge" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Debtor Standing Instr. Fail Charge</mat-label>
<input matInput type="number" formControlName="debtorStandingInstructionFailCharge" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Calculate Daily Against Clearing Charge</mat-label>
<mat-select formControlName="calculateDailyAgainstClearingCharge">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
<div class="lpch-section">
<div class="lpch-section__header">
<i class="fas fa-wallet"></i>
<h3>Minimum Balance Charges</h3>
</div>
<div class="lpch-section__body lpch-grid-4">
<mat-form-field appearance="outline">
<mat-label>Minimum Balance Checking</mat-label>
<mat-select formControlName="minimumBalanceCheckingType">
<mat-option value="STRICT">Strict</mat-option>
<mat-option value="AVERAGE">Average</mat-option>
<mat-option value="LENIENT">Lenient</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Auto Minimum Balance Charge</mat-label>
<mat-select formControlName="autoMinimumBalanceCharge">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Calculate Daily Min. Balance Charges</mat-label>
<mat-select formControlName="calculateDailyMinimumBalanceCharges">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Minimum Balance Charge</mat-label>
<mat-select formControlName="minimumBalanceChargeEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Min. Balance Charge (Rs.)</mat-label>
<input matInput type="number" formControlName="minimumBalanceChargeAmount" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Min. Balance Charge For Cheque Issue (Rs.)</mat-label>
<input matInput type="number" formControlName="minimumBalanceChargeForChequeIssue" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Min. Balance Charge Method</mat-label>
<mat-select formControlName="minimumBalanceChargeMethod">
<mat-option value="FLAT">Flat</mat-option>
<mat-option value="SLAB">Slab</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Months After Min. Balance Charge Calculate</mat-label>
<input matInput type="number" formControlName="monthsAfterMinBalanceChargeCalculate" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>For Cheque Issue</mat-label>
<input matInput type="number" formControlName="forChequeIssue" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>For Cheque Issue (Avg.)</mat-label>
<input matInput type="number" formControlName="forChequeIssueAvg" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>For Withdrawal Slip</mat-label>
<input matInput type="number" formControlName="forWithdrawalSlip" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>For Withdrawal Slip (Avg.)</mat-label>
<input matInput type="number" formControlName="forWithdrawalSlipAvg" />
</mat-form-field>
</div>
</div>
</form>
@@ -88,27 +88,3 @@ $gray-900: #202124;
.lpch-table {
width: 100%;
}
.lpch-section__body {
display: grid;
grid-template-columns: repeat(2, 1fr);
align-items: start;
gap: 18px 16px;
padding: 20px 24px;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.lpch-section__body.lpch-grid-4 {
grid-template-columns: repeat(4, 1fr);
@media (width <= 1100px) {
grid-template-columns: repeat(2, 1fr);
}
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
@@ -7,8 +7,7 @@
*/
import { Component, Input, OnInit, inject } from '@angular/core';
import { UntypedFormControl, UntypedFormBuilder, UntypedFormGroup, ReactiveFormsModule } from '@angular/forms';
import { patchCustomFields } from '../../loan-product-custom-fields';
import { UntypedFormControl } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { TranslateService } from '@ngx-translate/core';
@@ -52,16 +51,12 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatRow,
ChargesFilterPipe,
ChargesPenaltyFilterPipe,
FormatNumberPipe,
ReactiveFormsModule
FormatNumberPipe
]
})
export class LoanProductChargesStepComponent implements OnInit {
dialog = inject(MatDialog);
private translateService = inject(TranslateService);
private formBuilder = inject(UntypedFormBuilder);
legacyChargesForm: UntypedFormGroup;
@Input() loanProductsTemplate: any;
@Input() currencyCode: UntypedFormControl;
@@ -81,48 +76,6 @@ export class LoanProductChargesStepComponent implements OnInit {
pristine = true;
constructor() {
this.createLegacyChargesForm();
}
createLegacyChargesForm() {
this.legacyChargesForm = this.formBuilder.group({
// Cheque-related charges (legacy "A/c Mas Scr2 Para")
chequeBookChargeEnabled: ['NO'],
chequeReturnChargeEnabled: ['NO'],
icChargeDebitEnabled: ['NO'],
cashBundleChargeEnabled: ['NO'],
cashBundleChargeWithPanLimit: [''],
// Cheque transaction charges (legacy Page 4)
perChequeEntryCharge: [''],
perChequeEntryServiceCharge: [''],
perInwardChequeEntryCharge: [''],
perOutwardChequeEntryCharge: [''],
// Standing Instruction fail charges (legacy Page 4)
creditorStandingInstructionFailCharge: [''],
debtorStandingInstructionFailCharge: [''],
// Minimum Balance Information (legacy Page 2 + Page 4)
minimumBalanceCheckingType: [''],
autoMinimumBalanceCharge: [''],
calculateDailyMinimumBalanceCharges: ['NO'],
minimumBalanceChargeEnabled: ['NO'],
minimumBalanceChargeAmount: [''],
minimumBalanceChargeForChequeIssue: [''],
minimumBalanceChargeMethod: [''],
monthsAfterMinBalanceChargeCalculate: [''],
forChequeIssue: [''],
forChequeIssueAvg: [''],
forWithdrawalSlip: [''],
forWithdrawalSlipAvg: [''],
// Against Clearing charge (legacy Page 4)
calculateDailyAgainstClearingCharge: ['NO']
});
}
ngOnInit() {
this.chargeData = this.loanProductsTemplate.chargeOptions;
this.overdueChargeData = this.loanProductsTemplate.penaltyOptions
@@ -136,10 +89,6 @@ export class LoanProductChargesStepComponent implements OnInit {
this.currencyCode.valueChanges.subscribe(() => (this.chargesDataSource = []));
this.multiDisburseLoan.valueChanges.subscribe(() => (this.chargesDataSource = []));
// Repopulate the legacy "Account Type Creation" fields saved against this product.
// Last, so a value actually stored wins over any default set above.
patchCustomFields(this.legacyChargesForm, this.loanProductsTemplate);
}
addCharge(charge: any) {
@@ -163,8 +112,7 @@ export class LoanProductChargesStepComponent implements OnInit {
get loanProductCharges() {
return {
charges: this.chargesDataSource,
...this.legacyChargesForm.value
charges: this.chargesDataSource
};
}
}
@@ -1,11 +0,0 @@
{
"folders": [
{
"path": "../../../../../.."
},
{
"path": "../../../../../../../cbsnexora-backend"
}
],
"settings": {}
}
@@ -7,144 +7,40 @@
-->
<form [formGroup]="loanProductDetailsForm">
<!-- Product Classification + General Information — merged single section, 2-column layout -->
<div class="lpd-section">
<div class="lpd-section__header">
<i class="fas fa-info-circle"></i>
<h3>General Information</h3>
<h3>{{ 'labels.inputs.Product Name' | translate }} &amp; {{ 'labels.inputs.Description' | translate }}</h3>
</div>
<div class="lpd-two-col">
<div class="lpd-two-col__column">
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Account Type Group</mat-label>
<mat-select formControlName="productTypeGroup">
@for (group of productTypeGroups; track group.id) {
<mat-option [value]="group.id">{{ group.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field lpd-field--full">
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
<input
matInput
formControlName="name"
matTooltip="{{ 'tooltips.Product name is a unique identifier' | translate }}"
required
/>
<mat-error>
{{ 'labels.inputs.Product Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
@if (availableSubGroups.length) {
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Product Type Sub Group</mat-label>
<mat-select formControlName="productTypeSubGroup">
@for (subGroup of availableSubGroups; track subGroup.id) {
<mat-option [value]="subGroup.id">{{ subGroup.label }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>A/c. Type Description</mat-label>
<input
matInput
formControlName="name"
matTooltip="{{ 'tooltips.Product name is a unique identifier' | translate }}"
required
/>
<mat-error>
{{ 'labels.inputs.Product Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>{{ 'labels.inputs.Short Name' | translate }}</mat-label>
<input
matInput
formControlName="shortName"
maxlength="4"
matTooltip="{{ 'tooltips.Short name is a unique identifier' | translate }}"
required
/>
<mat-error>
{{ 'labels.inputs.Short Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>A/c Type Code</mat-label>
<input matInput formControlName="accountTypeCode" maxlength="2" />
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>MICR Trn. Code</mat-label>
<input matInput formControlName="micrTransactionCode" />
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Reserved Code Srno For ECS</mat-label>
<input matInput formControlName="reservedEcsSerialNumber" />
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Reverse ECS No.</mat-label>
<input matInput formControlName="reverseEcsNumber" />
</mat-form-field>
</div>
<div class="lpd-two-col__column">
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Lock Cheque Info</mat-label>
<mat-select formControlName="lockChequeInfoEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Lock Page 2 Info</mat-label>
<mat-select formControlName="lockPageTwoInfoEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field" (click)="computerizationDatePicker.open()">
<mat-label>Computerization Date</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="computerizationDatePicker"
formControlName="computerizationDate"
/>
<mat-datepicker-toggle matSuffix [for]="computerizationDatePicker"></mat-datepicker-toggle>
<mat-datepicker #computerizationDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>PFMS Account Type</mat-label>
<input matInput formControlName="pfmsAccountType" />
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Chq. Print Description</mat-label>
<input matInput formControlName="chequePrintDescription" />
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>POS A/c Type Description</mat-label>
<input matInput formControlName="posAccountTypeDescription" />
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Renewal Dt. Add Months</mat-label>
<input matInput type="number" formControlName="renewalDateAddMonths" />
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Renewal Grace Days</mat-label>
<input matInput type="number" formControlName="renewalGraceDays" />
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Doc. Renewal Dt. Add Months</mat-label>
<input matInput type="number" formControlName="documentRenewalDateAddMonths" />
</mat-form-field>
</div>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>{{ 'labels.inputs.Short Name' | translate }}</mat-label>
<input
matInput
formControlName="shortName"
maxlength="4"
matTooltip="{{ 'tooltips.Short name is a unique identifier' | translate }}"
required
/>
<mat-error>
{{ 'labels.inputs.Short Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
</div>
</div>
@@ -201,94 +97,86 @@
<i class="fas fa-percentage"></i>
<h3>Interest Information</h3>
</div>
<div class="lpd-two-col">
<div class="lpd-two-col__column">
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Debit Int. Calculation</mat-label>
<mat-select formControlName="debitIntCalculation">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Separate Penal Int.</mat-label>
<mat-select formControlName="separatePenalInterest">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Final Int. Rounding</mat-label>
<mat-select formControlName="finalIntRounding">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (loanProductDetailsForm.get('finalIntRounding')?.value) {
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Rounding UpTo</mat-label>
<input matInput type="number" formControlName="roundingUpTo" min="0" />
</mat-form-field>
}
</div>
<div class="lpd-two-col__column">
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Credit Int. Calculation</mat-label>
<mat-select formControlName="creditIntCalculation">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Sub A/c Type Balance</mat-label>
<mat-select formControlName="subAccountTypeBalance">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="lpd-info-note">
<i class="fas fa-circle-info"></i>
GL Account mappings for these fields (Int. Receivables, Penal Int. Cr., RD Matured, Int. Payable, Deaf Int.
Payable, Int. Overdue Reserve, UnDebited Interest) are available in the <strong>Accounting</strong> tab.
</div>
</div>
<!-- PPS Limit & Write Off Details (legacy Page 1 bottom fields) -->
<div class="lpd-section">
<div class="lpd-section__header">
<i class="fas fa-ban"></i>
<h3>PPS Limit &amp; Write Off Details</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>PPS Limit</mat-label>
<input matInput type="number" formControlName="ppsLimit" />
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Is this Actyp Write Off?</mat-label>
<mat-select formControlName="isWriteOffAccountType">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
<mat-label>Debit Int. Calculation</mat-label>
<mat-select formControlName="debitIntCalculation">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (loanProductDetailsForm.get('isWriteOffAccountType')?.value === 'YES') {
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Separate Penal Int.</mat-label>
<mat-select formControlName="separatePenalInterest">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Credit Int. Calculation</mat-label>
<mat-select formControlName="creditIntCalculation">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Sub A/c Type Balance</mat-label>
<mat-select formControlName="subAccountTypeBalance">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Final Int. Rounding</mat-label>
<mat-select formControlName="finalIntRounding">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (loanProductDetailsForm.get('finalIntRounding')?.value) {
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Write Off Actyp</mat-label>
<input matInput formControlName="writeOffAccountTypeCode" />
<mat-label>Rounding UpTo</mat-label>
<input matInput type="number" formControlName="roundingUpTo" min="0" />
</mat-form-field>
}
</div>
</div>
<!-- Product Classification + GL Account — combined section -->
<div class="lpd-section">
<div class="lpd-section__header">
<i class="fas fa-sitemap"></i>
<h3>Product Classification</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Product Type Group</mat-label>
<mat-select formControlName="productTypeGroup">
@for (group of productTypeGroups; track group.id) {
<mat-option [value]="group.id">{{ group.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (loanProductDetailsForm.get('productTypeGroup')?.value) {
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>GL Account ({{ glClassificationLabel }})</mat-label>
<mat-select formControlName="glAccount">
@for (acc of availableGlAccounts; track acc.id) {
<mat-option [value]="acc.id"> ({{ acc.glCode }}) {{ acc.name }} </mat-option>
}
</mat-select>
<mat-error>GL Account is required</mat-error>
</mat-form-field>
}
</div>
@@ -46,10 +46,9 @@ $gray-900: #202124;
.lpd-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
align-items: start;
gap: 18px 16px;
padding: 20px 24px;
grid-template-columns: repeat(2, 1fr);
gap: 4px 16px;
padding: 16px 24px;
@media (width <= 600px) {
grid-template-columns: 1fr;
@@ -58,7 +57,6 @@ $gray-900: #202124;
.lpd-field {
width: 100%;
min-width: 0;
&--full {
grid-column: 1 / -1;
@@ -71,43 +69,6 @@ $gray-900: #202124;
padding: 4px 0;
}
// Two independent vertical columns of fields, side by side
.lpd-two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 40px;
padding: 20px 24px;
align-items: start;
@media (width <= 700px) {
grid-template-columns: 1fr;
}
&__column {
display: flex;
flex-direction: column;
}
}
.lpd-info-note {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 0 24px 16px;
padding: 10px 14px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
font-size: 12.5px;
color: #1e3a8a;
line-height: 1.5;
i {
color: #2563eb;
margin-top: 2px;
}
}
// GL Section
.lpd-gl-section {
border-top: 2px solid #e0f2fe;
@@ -7,9 +7,8 @@
*/
/** Angular Imports */
import { Component, OnInit, Input, Output, EventEmitter, inject } from '@angular/core';
import { Component, OnInit, Input, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { CUSTOM_DATE_FIELDS, patchCustomFields } from '../../loan-product-custom-fields';
import { Dates } from 'app/core/utils/dates';
/** Custom Services */
@@ -34,7 +33,6 @@ export class LoanProductDetailsStepComponent implements OnInit {
private settingsService = inject(SettingsService);
@Input() loanProductsTemplate: any;
@Output() productSubGroupChange = new EventEmitter<string>();
loanProductDetailsForm: UntypedFormGroup;
@@ -51,10 +49,6 @@ export class LoanProductDetailsStepComponent implements OnInit {
this.createLoanProductDetailsForm();
}
assetAccountOptions: any[] = [];
incomeAccountOptions: any[] = [];
liabilityAccountOptions: any[] = [];
ngOnInit() {
this.loanProductDetailsForm.patchValue({
name: this.loanProductsTemplate.name,
@@ -64,6 +58,10 @@ export class LoanProductDetailsStepComponent implements OnInit {
closeDate: this.loanProductsTemplate.closeDate && new Date(this.loanProductsTemplate.closeDate)
});
const mappingOptions = this.loanProductsTemplate?.accountingMappingOptions ?? {};
this.assetAccountOptions = mappingOptions.assetAccountOptions ?? [];
this.liabilityAccountOptions = mappingOptions.liabilityAccountOptions ?? [];
this.loanProductDetailsForm.patchValue({
debitIntCalculation: this.loanProductsTemplate.debitIntCalculation ?? false,
separatePenalInterest: this.loanProductsTemplate.separatePenalInterest ?? false,
@@ -72,63 +70,66 @@ export class LoanProductDetailsStepComponent implements OnInit {
finalIntRounding: this.loanProductsTemplate.finalIntRounding ?? false,
roundingUpTo: this.loanProductsTemplate.roundingUpTo ?? null
});
const mappingOptions = this.loanProductsTemplate?.accountingMappingOptions ?? {};
this.assetAccountOptions = mappingOptions.assetAccountOptions ?? [];
this.incomeAccountOptions = mappingOptions.incomeAccountOptions ?? [];
this.liabilityAccountOptions = mappingOptions.liabilityAccountOptions ?? [];
const accountingMappings = this.loanProductsTemplate?.accountingMappings;
if (accountingMappings) {
this.loanProductDetailsForm.patchValue({
intReceivablesGlAccountId: accountingMappings.intReceivablesGlAccount?.id || '',
penalIntCrGlAccountId: accountingMappings.penalIntCrGlAccount?.id || '',
rdMaturedGlAccountId: accountingMappings.rdMaturedGlAccount?.id || '',
intPayableGlAccountId: accountingMappings.intPayableGlAccount?.id || '',
deafIntPayableGlAccountId: accountingMappings.deafIntPayableGlAccount?.id || '',
intOverdueReserveAccountId: accountingMappings.intOverdueReserveAccount?.id || '',
undebitedInterestAccountId: accountingMappings.undebitedInterestAccount?.id || ''
});
}
// Repopulate the legacy "Account Type Creation" fields saved against this product.
// Last, so a value actually stored wins over any default set above.
patchCustomFields(this.loanProductDetailsForm, this.loanProductsTemplate);
}
/** Product Classification data */
readonly productTypeGroups = [
{ id: 'advances', label: 'Advances A/C' },
{ id: 'loans', label: 'Loans A/C' }
{ id: 'loans', label: 'Loan' },
{ id: 'advanced', label: 'Advanced' }
];
readonly productTypeSubGroups: Record<string, { id: string; label: string }[]> = {
// Renewable, Drawing-Power/limit-based revolving credit facilities
advances: [
{ id: 'cc', label: 'Cash Credit (CC)' },
{ id: 'overdraft', label: 'Overdraft (OD)' },
{ id: 'gold', label: 'Gold Loan' },
{ id: 'warehouse', label: 'Warehouse Receipt / Pledge Loan' },
{ id: 'kcc', label: 'Kisan Credit Card (KCC)' },
{ id: 'bills', label: 'Bills Purchased / Discounted' }
],
// Fixed disbursement, fixed tenure, EMI-based term credit facilities
loans: [
{ id: 'term', label: 'Term Loan / Long Term Loan' },
{ id: 'personal', label: 'Personal Loan' },
{ id: 'housing', label: 'Housing Loan' },
{ id: 'vehicle', label: 'Vehicle Loan' },
{ id: 'education', label: 'Education Loan' },
{ id: 'business', label: 'Business / MSME Term Loan' },
{ id: 'gold_emi', label: 'Gold EMI Loan' }
{ id: 'emi', label: 'EMI' },
{ id: 'cc', label: 'CC (Cash Credit)' },
{ id: 'overdraft', label: 'Overdraft' },
{ id: 'warehouse', label: 'Warehouse' },
{ id: 'gold', label: 'Gold' }
],
advanced: [
{ id: 'fd', label: 'FD (Fixed Deposit)' },
{ id: 'rd', label: 'RD (Recurring Deposit)' }
]
};
/** Default GL per product type group */
private readonly glDefaultMap: Record<string, string> = {
loans: 'asset',
advanced: 'asset'
};
get availableSubGroups() {
const group = this.loanProductDetailsForm.get('productTypeGroup')?.value;
return group ? (this.productTypeSubGroups[group] ?? []) : [];
}
get glOptions(): string[] {
return [
'asset',
'liability'
];
}
assetAccountOptions: any[] = [];
liabilityAccountOptions: any[] = [];
get availableGlAccounts(): any[] {
const type = this.loanProductDetailsForm.get('glClassification')?.value;
if (type === 'asset') return this.assetAccountOptions;
if (type === 'liability') return this.liabilityAccountOptions;
return [];
}
get glClassificationLabel(): string {
const type = this.loanProductDetailsForm.get('glClassification')?.value;
return type === 'liability' ? 'Liability' : 'Asset';
}
get selectedGroupLabel(): string {
const group = this.loanProductDetailsForm.get('productTypeGroup')?.value;
return this.productTypeGroups.find((g) => g.id === group)?.label ?? '';
}
readonly yesNoOptions = [
{ value: true, label: 'YES' },
{ value: false, label: 'NO' }
@@ -149,46 +150,28 @@ export class LoanProductDetailsStepComponent implements OnInit {
closeDate: [''],
productTypeGroup: [''],
productTypeSubGroup: [''],
glClassification: ['asset'],
glAccount: [''],
debitIntCalculation: [false],
separatePenalInterest: [false],
creditIntCalculation: [false],
subAccountTypeBalance: [false],
finalIntRounding: [false],
roundingUpTo: [null],
// General Information (legacy Page 1 header fields)
accountTypeCode: [''],
micrTransactionCode: [''],
reservedEcsSerialNumber: [''],
reverseEcsNumber: [''],
lockChequeInfoEnabled: ['NO'],
lockPageTwoInfoEnabled: ['NO'],
computerizationDate: [''],
chequePrintDescription: [''],
posAccountTypeDescription: [''],
pfmsAccountType: [''],
renewalDateAddMonths: [''],
renewalGraceDays: [''],
documentRenewalDateAddMonths: [''],
// PPS Limit & Write Off Details
ppsLimit: [''],
isWriteOffAccountType: ['NO'],
writeOffAccountTypeCode: ['']
roundingUpTo: [null]
});
this.loanProductDetailsForm.get('productTypeSubGroup')?.valueChanges.subscribe((subGroup: string) => {
this.productSubGroupChange.emit(subGroup);
// Auto-set GL based on product type group
this.loanProductDetailsForm.get('productTypeGroup')?.valueChanges.subscribe((group: string) => {
const defaultVal = this.glDefaultMap[group] ?? 'asset';
this.loanProductDetailsForm.get('glClassification')?.setValue(defaultVal);
this.loanProductDetailsForm.get('glAccount')?.setValue('');
});
}
get loanProductDetails() {
// getRawValue() rather than value: it returns a fresh object, so the date reformatting below
// cannot write back into the form's own cached value, and any control disabled later still
// reaches the preview step instead of silently vanishing from it.
const loanProductDetailsFormData = this.loanProductDetailsForm.getRawValue();
const prevStartDate: Date = loanProductDetailsFormData.startDate;
const prevCloseDate: Date = loanProductDetailsFormData.closeDate;
const loanProductDetailsFormData = this.loanProductDetailsForm.value;
const prevStartDate: Date = this.loanProductDetailsForm.value.startDate;
const prevCloseDate: Date = this.loanProductDetailsForm.value.closeDate;
const dateFormat = this.settingsService.dateFormat;
if (loanProductDetailsFormData.startDate instanceof Date) {
loanProductDetailsFormData.startDate = this.dateUtils.formatDate(prevStartDate, dateFormat) || '';
@@ -196,17 +179,6 @@ export class LoanProductDetailsStepComponent implements OnInit {
if (loanProductDetailsFormData.closeDate instanceof Date) {
loanProductDetailsFormData.closeDate = this.dateUtils.formatDate(prevCloseDate, dateFormat) || '';
}
// The legacy date fields need the same treatment: their control holds a Date, but the API parses
// them against the dateFormat sent alongside, and rejects the raw value outright. Driven off
// CUSTOM_DATE_FIELDS rather than named here, so a legacy date added later is covered too.
for (const field of CUSTOM_DATE_FIELDS) {
const value = loanProductDetailsFormData[field];
if (value instanceof Date) {
loanProductDetailsFormData[field] = this.dateUtils.formatDate(value, dateFormat) || '';
}
}
return loanProductDetailsFormData;
}
}
@@ -235,361 +235,209 @@
</div>
</div>
<mat-accordion class="lps-accordion" multi="true">
@if (showChequeBookSettings) {
<mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
<i class="fas fa-money-check lps-section__icon"></i>
Cheque Book Settings
</mat-panel-title>
</mat-expansion-panel-header>
<div class="lps-section__body lps-grid-4">
<mat-form-field appearance="outline">
<mat-label>Cheque Book Issue Enabled</mat-label>
<mat-select formControlName="chequeBookIssueEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<!-- Cheque Book Settings -->
<div class="lps-section">
<div class="lps-section__header">
<i class="fas fa-money-check lps-section__icon"></i>
<h3 class="lps-section__title">Cheque Book Settings</h3>
</div>
<div class="lps-section__body lps-grid-4">
<mat-form-field appearance="outline">
<mat-label>Cheque Book Issue Enabled</mat-label>
<mat-select formControlName="chequeBookIssueEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Cheque Validation Required</mat-label>
<mat-select formControlName="chequeValidationRequired">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Cheque Validation Required</mat-label>
<mat-select formControlName="chequeValidationRequired">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Is First Cheque Book Free</mat-label>
<mat-select formControlName="isFirstChequeBookFree">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Is First Cheque Book Free</mat-label>
<mat-select formControlName="isFirstChequeBookFree">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Cheque Printing Account Type</mat-label>
<mat-select formControlName="chequePrintingAccountType">
<mat-option value="SAVINGS_BANK">Savings Bank</mat-option>
<mat-option value="CURRENT_ACCOUNT">Current Account</mat-option>
<mat-option value="CASH_CREDIT">Cash Credit</mat-option>
<mat-option value="OVERDRAFT">Overdraft</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Cheque Printing Account Type</mat-label>
<mat-select formControlName="chequePrintingAccountType">
<mat-option value="SAVINGS_BANK">Savings Bank</mat-option>
<mat-option value="CURRENT_ACCOUNT">Current Account</mat-option>
<mat-option value="CASH_CREDIT">Cash Credit</mat-option>
<mat-option value="OVERDRAFT">Overdraft</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Allow Withdrawal Slip Usage</mat-label>
<mat-select formControlName="allowWithdrawalSlipUsage">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Allow Withdrawal Slip Usage</mat-label>
<mat-select formControlName="allowWithdrawalSlipUsage">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Maintain Cheque Book Stock Registry</mat-label>
<mat-select formControlName="maintainChequeBookStockRegistry">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Maintain Cheque Book Stock Registry</mat-label>
<mat-select formControlName="maintainChequeBookStockRegistry">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Free Cheque Books Per Year</mat-label>
<input type="number" matInput formControlName="freeChequeBooksPerYear" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Free Cheque Books Per Year</mat-label>
<input type="number" matInput formControlName="freeChequeBooksPerYear" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Allow Credit By Cheque Number</mat-label>
<mat-select formControlName="allowCreditByChequeNumber">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Allow Credit By Cheque Number</mat-label>
<mat-select formControlName="allowCreditByChequeNumber">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Credit Cheque Validation Required</mat-label>
<mat-select formControlName="creditChequeValidationRequired">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Credit Cheque Validation Required</mat-label>
<mat-select formControlName="creditChequeValidationRequired">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Grace Days For Stock Statement</mat-label>
<input type="number" matInput formControlName="graceDaysForStockStatement" />
</mat-form-field>
</div>
</mat-expansion-panel>
}
<mat-form-field appearance="outline">
<mat-label>Grace Days For Stock Statement</mat-label>
<input type="number" matInput formControlName="graceDaysForStockStatement" />
</mat-form-field>
</div>
</div>
<mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
<i class="fas fa-cogs lps-section__icon"></i>
Additional Product Settings
</mat-panel-title>
</mat-expansion-panel-header>
<div class="lps-section__body lps-grid-4">
<mat-form-field appearance="outline">
<mat-label>Account Type Active For Transactions</mat-label>
<mat-select formControlName="accountTypeActiveForTransactions">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<!-- Additional Product Settings -->
<div class="lps-section">
<div class="lps-section__header">
<i class="fas fa-cogs lps-section__icon"></i>
<h3 class="lps-section__title">Additional Product Settings</h3>
</div>
<div class="lps-section__body lps-grid-4">
<mat-form-field appearance="outline">
<mat-label>Account Type Active For Transactions</mat-label>
<mat-select formControlName="accountTypeActiveForTransactions">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Sub Type Activity Enabled</mat-label>
<mat-select formControlName="subTypeActivityEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Sub Type Activity Enabled</mat-label>
<mat-select formControlName="subTypeActivityEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Account Opening Charges</mat-label>
<input type="number" matInput formControlName="accountOpeningCharges" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Account Opening Charges</mat-label>
<input type="number" matInput formControlName="accountOpeningCharges" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Maximum Gold Loan Limit</mat-label>
<input type="number" matInput formControlName="maximumGoldLoanLimit" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Maximum Gold Loan Limit</mat-label>
<input type="number" matInput formControlName="maximumGoldLoanLimit" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Gold Loan [Packet No.] With Year?</mat-label>
<mat-select formControlName="goldLoanPacketNumberWithYearEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Last Account Number Opened</mat-label>
<input type="text" matInput formControlName="lastAccountNumberOpened" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Last Account Number Opened</mat-label>
<input type="text" matInput formControlName="lastAccountNumberOpened" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Customer Termination Allowed</mat-label>
<mat-select formControlName="customerTerminationAllowed">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Customer Termination Allowed</mat-label>
<mat-select formControlName="customerTerminationAllowed">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Charges For Cash Credit Above Limit</mat-label>
<input type="number" matInput formControlName="chargesForCashCreditAboveLimit" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Charges For Cash Credit Above Limit</mat-label>
<input type="number" matInput formControlName="chargesForCashCreditAboveLimit" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Death Interest GL Account</mat-label>
<input type="text" matInput formControlName="deathInterestGLAccount" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Death Interest GL Account</mat-label>
<input type="text" matInput formControlName="deathInterestGLAccount" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Starting Ledger Number</mat-label>
<input type="number" matInput formControlName="startingLedgerNumber" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Starting Ledger Number</mat-label>
<input type="number" matInput formControlName="startingLedgerNumber" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Ending Ledger Number</mat-label>
<input type="number" matInput formControlName="endingLedgerNumber" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Ending Ledger Number</mat-label>
<input type="number" matInput formControlName="endingLedgerNumber" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Current Ledger Number</mat-label>
<input type="number" matInput formControlName="currentLedgerNumber" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Current Ledger Number</mat-label>
<input type="number" matInput formControlName="currentLedgerNumber" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Remittance Account Number</mat-label>
<input type="text" matInput formControlName="remittanceAccountNumber" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Remittance Account Number</mat-label>
<input type="text" matInput formControlName="remittanceAccountNumber" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Is Non Resident Indian Account</mat-label>
<mat-select formControlName="isNonResidentIndianAccount">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Is Non Resident Indian Account</mat-label>
<mat-select formControlName="isNonResidentIndianAccount">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Debit Clearing GL Account</mat-label>
<input type="text" matInput formControlName="debitClearingGLAccount" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Debit Clearing GL Account</mat-label>
<input type="text" matInput formControlName="debitClearingGLAccount" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Missed Call Alert Service Active</mat-label>
<mat-select formControlName="missedCallAlertServiceActive">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Missed Call Alert Service Active</mat-label>
<mat-select formControlName="missedCallAlertServiceActive">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Standing Instruction Charge GL Account</mat-label>
<input type="text" matInput formControlName="standingInstructionChargeGLAccount" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Standing Instruction Charge GL Account</mat-label>
<input type="text" matInput formControlName="standingInstructionChargeGLAccount" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Standing Instruction Debit Charge Skip Option</mat-label>
<mat-select formControlName="standingInstructionDebitChargeSkipOption">
<mat-option value="SKIP_ALWAYS">Skip Always</mat-option>
<mat-option value="SKIP_ON_FIRST_FAILURE">Skip on First Failure</mat-option>
<mat-option value="NEVER_SKIP">Never Skip</mat-option>
<mat-option value="RETRY_AFTER_24_HOURS">Retry after 24 Hours</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Standing Instruction Debit Charge Skip Option</mat-label>
<mat-select formControlName="standingInstructionDebitChargeSkipOption">
<mat-option value="SKIP_ALWAYS">Skip Always</mat-option>
<mat-option value="SKIP_ON_FIRST_FAILURE">Skip on First Failure</mat-option>
<mat-option value="NEVER_SKIP">Never Skip</mat-option>
<mat-option value="RETRY_AFTER_24_HOURS">Retry after 24 Hours</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Opening Balance Multiplier For Remittance Charges</mat-label>
<input type="number" matInput formControlName="openingBalanceMultiplierForRemittanceCharges" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>A/c Move To InOperative A/c</mat-label>
<mat-select formControlName="accountMoveToInoperativeEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Generate GL Diff Entry</mat-label>
<mat-select formControlName="generateGlDiffEntryEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Sub Type Wise GL Balance Gen</mat-label>
<mat-select formControlName="subTypeWiseGlBalanceGenerationEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Want To Validation While Deleting Debit Entry</mat-label>
<mat-select formControlName="validateBeforeDeletingDebitEntryEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Want To RC File Concept</mat-label>
<mat-select formControlName="rcFileConceptEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>A/c Close With Zero Bal. In Open Day Process</mat-label>
<mat-select formControlName="accountCloseZeroBalanceInOpenDayProcess">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>A/c Close When Bal. Zero At Subled Tran Entry</mat-label>
<mat-select formControlName="accountCloseZeroBalanceAtSubledgerTranEntry">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Lmt.Enh/Ag.Clg Screen Display</mat-label>
<mat-select formControlName="limitEnhancementAgainstClearingScreenDisplayEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Advance Int. Param In Ledger</mat-label>
<mat-select formControlName="advanceInterestParameterInLedgerEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Vehicle Detail In Master</mat-label>
<mat-select formControlName="vehicleDetailInMasterEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Deleted Chq. Issue</mat-label>
<mat-select formControlName="deletedChequeIssueEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>DP Reduce In Doc. Renewal Date</mat-label>
<mat-select formControlName="dpReduceInDocumentRenewalDateEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>DP Reduce In Renewal Date</mat-label>
<mat-select formControlName="dpReduceInRenewalDateEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Auto Close Day-End</mat-label>
<mat-select formControlName="autoCloseAtDayEndEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Loan Rate From Master</mat-label>
<mat-select formControlName="loanRateFromMasterEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Cheque Short Name</mat-label>
<input type="text" matInput formControlName="chequeShortName" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Statement Print</mat-label>
<mat-select formControlName="statementPrintEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>NESL Code</mat-label>
<input type="text" matInput formControlName="neslCode" />
</mat-form-field>
</div>
</mat-expansion-panel>
</mat-accordion>
<mat-form-field appearance="outline">
<mat-label>Opening Balance Multiplier For Remittance Charges</mat-label>
<input type="number" matInput formControlName="openingBalanceMultiplierForRemittanceCharges" />
</mat-form-field>
</div>
</div>
<!-- Interest Related Parameters (Page 3) -->
<div class="lps-section">
@@ -906,85 +754,6 @@
<mat-label>Add Penal Interest Rate</mat-label>
<input type="number" matInput formControlName="addPenalInterestRate" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Atyp14 Simple Int.</mat-label>
<mat-select formControlName="atyp14SimpleInterestEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>FDOD Margin Type</mat-label>
<input type="text" matInput formControlName="fixedDepositOverdraftMarginType" />
</mat-form-field>
<mat-form-field appearance="outline" class="lps-span-2">
<mat-label>Check Master Detail Edited (Passing Pend.)</mat-label>
<mat-select formControlName="checkMasterDetailEditedPassingPendingEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lps-span-2">
<mat-label>Credit/Debit Int. Calc. Separate</mat-label>
<mat-select formControlName="creditDebitInterestCalculationSeparateEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>On Time Interest Provision</mat-label>
<mat-select formControlName="onTimeInterestProvisionEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Check Against Clearing</mat-label>
<mat-select formControlName="checkAgainstClearingEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>GST On Penalty</mat-label>
<mat-select formControlName="gstOnPenaltyEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Penalty Int. Grace Days</mat-label>
<input type="number" min="0" matInput formControlName="penaltyInterestGraceDays" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Over Drawn Int. Rate</mat-label>
<input type="number" matInput formControlName="overdrawnInterestRate" />
</mat-form-field>
<mat-form-field appearance="outline" class="lps-span-2">
<mat-label>Calculate Daily Limit Enhancement (TOD)</mat-label>
<mat-select formControlName="calculateDailyLimitEnhancementTodEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lps-span-2">
<mat-label>FDOD Multiple Int. Rate</mat-label>
<mat-select formControlName="fixedDepositOverdraftMultipleInterestRateEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
@@ -1032,306 +801,6 @@
</div>
</div>
<mat-accordion class="lps-accordion" multi="true">
@if (showChannelLimits) {
<mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
<i class="fas fa-network-wired lps-section__icon"></i>
Channel &amp; Transaction Limits
</mat-panel-title>
</mat-expansion-panel-header>
<div class="lps-section__body lps-grid-4">
<mat-form-field appearance="outline">
<mat-label>Is Mobile App?</mat-label>
<mat-select formControlName="isMobileAppEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Is ATM?</mat-label>
<mat-select formControlName="isAtmEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
@if (loanProductSettingsForm.value.isAtmEnabled === 'YES') {
<mat-form-field appearance="outline">
<mat-label>ATM Yearly Charge?</mat-label>
<mat-select formControlName="atmYearlyChargeEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>ATM Min. Balance</mat-label>
<input type="number" matInput formControlName="atmMinimumBalance" />
</mat-form-field>
}
<mat-form-field appearance="outline">
<mat-label>Email Active?</mat-label>
<mat-select formControlName="emailActiveEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Is IMPS?</mat-label>
<mat-select formControlName="isImpsEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Is UPI?</mat-label>
<mat-select formControlName="isUpiEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Is POS?</mat-label>
<mat-select formControlName="isPosEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Is BBPS?</mat-label>
<mat-select formControlName="isBbpsEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Inter Bank Limit (Per Day)</mat-label>
<input type="number" matInput formControlName="interBankLimitPerDay" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Inter Bank Limit (Per Transaction)</mat-label>
<input type="number" matInput formControlName="interBankLimitPerTransaction" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>NEFT/RTGS Limit (Per Day)</mat-label>
<input type="number" matInput formControlName="neftRtgsLimitPerDay" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>NEFT/RTGS Limit (Per Transaction)</mat-label>
<input type="number" matInput formControlName="neftRtgsLimitPerTransaction" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>NEFT Cutoff Limit</mat-label>
<input type="number" matInput formControlName="neftCutoffLimit" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>RTGS Cutoff Limit</mat-label>
<input type="number" matInput formControlName="rtgsCutoffLimit" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>NEFT/RTGS Min. Balance</mat-label>
<input type="number" matInput formControlName="neftRtgsMinimumBalance" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Is Charge Req. NEFT/RTGS (CBS)?</mat-label>
<mat-select formControlName="isChargeRequiredNeftRtgsCbs">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Is Charge Req. NEFT/RTGS (Mobile)?</mat-label>
<mat-select formControlName="isChargeRequiredNeftRtgsMobile">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
@if (loanProductSettingsForm.value.isImpsEnabled === 'YES') {
<mat-form-field appearance="outline">
<mat-label>IMPS Limit (Per Day)</mat-label>
<input type="number" matInput formControlName="impsLimitPerDay" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>IMPS Limit (Per Transaction)</mat-label>
<input type="number" matInput formControlName="impsLimitPerTransaction" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>IMPS Limit (Per Month)</mat-label>
<input type="number" matInput formControlName="impsLimitPerMonth" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>IMPS Min. Limit</mat-label>
<input type="number" matInput formControlName="impsMinLimit" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>IMPS Monthly Transaction Count</mat-label>
<input type="number" matInput formControlName="impsMonthlyTransactionCount" />
</mat-form-field>
}
</div>
</mat-expansion-panel>
}
@if (
showChannelLimits &&
(loanProductSettingsForm.value.isPosEnabled === 'YES' || loanProductSettingsForm.value.isAtmEnabled === 'YES')
) {
<mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
<i class="fas fa-credit-card lps-section__icon"></i>
POS Facility &amp; ATM CSV Settings
</mat-panel-title>
</mat-expansion-panel-header>
<div class="lps-section__body lps-grid-4">
<mat-form-field appearance="outline">
<mat-label>Max Per Transaction Limit (ATM)</mat-label>
<input type="number" matInput formControlName="maxPerTransactionLimitAtm" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Max Day Transaction Limit (ATM)</mat-label>
<input type="number" matInput formControlName="maxDayTransactionLimitAtm" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Max Per Transaction Limit (POS)</mat-label>
<input type="number" matInput formControlName="maxPerTransactionLimitPos" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Max Day Transaction Limit (POS)</mat-label>
<input type="number" matInput formControlName="maxDayTransactionLimitPos" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Max Per Transaction Limit (ECOM)</mat-label>
<input type="number" matInput formControlName="maxPerTransactionLimitEcom" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Max Day Transaction Limit (ECOM)</mat-label>
<input type="number" matInput formControlName="maxDayTransactionLimitEcom" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Max Per Transaction Limit (UPI)</mat-label>
<input type="number" matInput formControlName="maxPerTransactionLimitUpi" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Max Day Transaction Limit (UPI)</mat-label>
<input type="number" matInput formControlName="maxDayTransactionLimitUpi" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>ATM CSV Product Code</mat-label>
<input type="text" matInput formControlName="atmCsvProductCode" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>ATM CSV Type Code</mat-label>
<input type="text" matInput formControlName="atmCsvTypeCode" />
</mat-form-field>
</div>
</mat-expansion-panel>
}
<mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
<i class="fas fa-clock lps-section__icon"></i>
Auto Interest Post Parameters
</mat-panel-title>
</mat-expansion-panel-header>
<div class="lps-section__body lps-grid-4">
<mat-form-field appearance="outline">
<mat-label>Auto Int. Post Type</mat-label>
<mat-select formControlName="autoInterestPostType">
<mat-option value="MONTHLY">Monthly</mat-option>
<mat-option value="QUARTERLY">Quarterly</mat-option>
<mat-option value="HALF_YEARLY">Half Yearly</mat-option>
<mat-option value="YEARLY">Yearly</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Auto Int. Post When</mat-label>
<mat-select formControlName="autoInterestPostWhen">
<mat-option value="MONTH_END">Month End</mat-option>
<mat-option value="FIX_DAY">Fix Day</mat-option>
</mat-select>
</mat-form-field>
@if (loanProductSettingsForm.value.autoInterestPostWhen === 'FIX_DAY') {
<mat-form-field appearance="outline">
<mat-label>Fix Day</mat-label>
<input type="number" min="1" max="31" matInput formControlName="autoInterestPostFixDay" />
</mat-form-field>
}
<mat-form-field appearance="outline">
<mat-label>Auto Last Int. Date</mat-label>
<input type="text" matInput formControlName="autoLastInterestDate" placeholder="DD/MM/YYYY" />
</mat-form-field>
</div>
</mat-expansion-panel>
@if (showForeclosureSettings) {
<mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
<i class="fas fa-door-closed lps-section__icon"></i>
Foreclosure / Early Account Closure
</mat-panel-title>
</mat-expansion-panel-header>
<div class="lps-section__body lps-grid-4">
<mat-form-field appearance="outline">
<mat-label>Calculate Early A/c Close Charge?</mat-label>
<mat-select formControlName="calculateEarlyAccountCloseCharge">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
@if (loanProductSettingsForm.value.calculateEarlyAccountCloseCharge === 'YES') {
<mat-form-field appearance="outline">
<mat-label>Early A/c Close Charge</mat-label>
<input type="number" matInput formControlName="earlyAccountCloseCharge" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Early A/c Close Period (In Months)</mat-label>
<input type="number" matInput formControlName="earlyAccountClosePeriodInMonths" />
</mat-form-field>
}
</div>
</mat-expansion-panel>
}
</mat-accordion>
<!-- Credit Bureau Settings (CIBIL) -->
<div class="lps-section">
<div class="lps-section__header">
@@ -1414,11 +883,6 @@
<input type="number" min="0" matInput formControlName="graceOnInterestPayment" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Grace Days After Overdue For Moratorium A/c</mat-label>
<input type="number" min="0" matInput formControlName="graceDaysAfterOverdueForMoratorium" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Delinquency Bucket' | translate }}</mat-label>
<mat-select formControlName="delinquencyBucketId">
@@ -50,9 +50,8 @@
&__body {
display: grid;
grid-template-columns: repeat(2, 1fr);
align-items: start;
gap: 18px 16px;
padding: 20px 24px;
gap: 4px 16px;
padding: 16px 24px;
@media (width <= 600px) {
grid-template-columns: 1fr;
@@ -84,26 +83,6 @@
grid-column: span 3;
}
.lps-narrow {
max-width: 160px;
}
.lps-accordion {
margin-top: 16px;
.lps-section__body {
padding: 16px 24px 20px;
}
mat-panel-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
font-weight: 600;
}
}
.lps-checkbox-row {
display: flex;
align-items: center;
@@ -15,25 +15,12 @@ import {
ReactiveFormsModule
} from '@angular/forms';
import { LoanProducts } from '../../loan-products';
import {
CHANNEL_ENABLED_SUB_GROUPS,
CUSTOM_UI_ONLY_FIELDS,
NO_CHEQUE_BOOK_SUB_GROUPS,
TERM_LOAN_SUB_GROUPS,
patchCustomFields
} from '../../loan-product-custom-fields';
import { GlobalConfiguration } from 'app/system/configurations/global-configurations-tab/configuration.model';
import { CodeName, OptionData, StringEnumOptionData } from 'app/shared/models/option-data.model';
import { ProcessingStrategyService } from '../../services/processing-strategy.service';
import { MatTooltip } from '@angular/material/tooltip';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatIconButton } from '@angular/material/button';
import {
MatAccordion,
MatExpansionPanel,
MatExpansionPanelHeader,
MatExpansionPanelTitle
} from '@angular/material/expansion';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -47,10 +34,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatTooltip,
MatCheckbox,
MatIconButton,
MatAccordion,
MatExpansionPanel,
MatExpansionPanelHeader,
MatExpansionPanelTitle,
FaIconComponent,
ReactiveFormsModule
]
@@ -62,27 +45,8 @@ export class LoanProductSettingsStepComponent implements OnInit {
@Input() toEdit: boolean;
@Input() loanProductsTemplate: any;
@Input() isLinkedToFloatingInterestRates: UntypedFormControl;
@Input() productSubGroup: string;
@Output() advancePaymentStrategy = new EventEmitter<string>();
// The sub-group lists live in loan-product-custom-fields so the preview step can apply the same
// rule: it renders these sections from the form's values, and without the shared list it listed
// sections this step had hidden.
readonly channelEnabledSubGroups = CHANNEL_ENABLED_SUB_GROUPS;
readonly termLoanSubGroups = TERM_LOAN_SUB_GROUPS;
get showChannelLimits(): boolean {
return !this.productSubGroup || CHANNEL_ENABLED_SUB_GROUPS.includes(this.productSubGroup);
}
get showChequeBookSettings(): boolean {
return !this.productSubGroup || !NO_CHEQUE_BOOK_SUB_GROUPS.includes(this.productSubGroup);
}
get showForeclosureSettings(): boolean {
return !this.productSubGroup || TERM_LOAN_SUB_GROUPS.includes(this.productSubGroup);
}
loanProductSettingsForm: UntypedFormGroup;
amortizationTypeData: any;
@@ -283,10 +247,6 @@ export class LoanProductSettingsStepComponent implements OnInit {
}
});
}
// Repopulate the legacy "Account Type Creation" fields saved against this product.
// Last, so a value actually stored wins over any default set above.
patchCustomFields(this.loanProductSettingsForm, this.loanProductsTemplate);
}
createLoanProductSettingsForm() {
@@ -406,24 +366,6 @@ export class LoanProductSettingsStepComponent implements OnInit {
standingInstructionChargeGLAccount: [''],
standingInstructionDebitChargeSkipOption: [''],
openingBalanceMultiplierForRemittanceCharges: [''],
accountMoveToInoperativeEnabled: ['NO'],
generateGlDiffEntryEnabled: ['NO'],
subTypeWiseGlBalanceGenerationEnabled: ['NO'],
validateBeforeDeletingDebitEntryEnabled: ['NO'],
rcFileConceptEnabled: ['NO'],
accountCloseZeroBalanceInOpenDayProcess: ['NO'],
accountCloseZeroBalanceAtSubledgerTranEntry: ['NO'],
limitEnhancementAgainstClearingScreenDisplayEnabled: ['NO'],
advanceInterestParameterInLedgerEnabled: ['NO'],
vehicleDetailInMasterEnabled: ['NO'],
deletedChequeIssueEnabled: ['NO'],
dpReduceInDocumentRenewalDateEnabled: ['NO'],
dpReduceInRenewalDateEnabled: ['NO'],
autoCloseAtDayEndEnabled: ['NO'],
loanRateFromMasterEnabled: ['NO'],
chequeShortName: [''],
statementPrintEnabled: ['YES'],
neslCode: [''],
// Interest Related Parameters
drawingPowerAndLimitGenerationProcedure: ['NORMAL D.P. ENTRY IS CONSIDER'],
@@ -443,7 +385,7 @@ export class LoanProductSettingsStepComponent implements OnInit {
penaltyInterestDateDifferentThanRegularInterest: ['YES'],
fixedDepositOverdraftCertificateSortOrder: ['ORDER ON INTEREST RATE'],
useDocumentSubmissionDateForInterest: ['NO'],
loanManagementSystemRenewalPenaltyActive: ['NO'],
loanManagementSystemRenewalPenaltyActive: [''],
minimumNormalInterestAmount: [''],
minimumInterestCalculationMethod: [''],
minimumAgainstClearingInterestAmount: [''],
@@ -467,18 +409,6 @@ export class LoanProductSettingsStepComponent implements OnInit {
nonPerformingAssetPenalInterestRate: [''],
fixedDepositOverdraftPenalInterestRateUpdateInBranchProcess: ['YES'],
addPenalInterestRate: [''],
atyp14SimpleInterestEnabled: ['NO'],
fixedDepositOverdraftMarginType: [''],
checkMasterDetailEditedPassingPendingEnabled: ['NO'],
creditDebitInterestCalculationSeparateEnabled: ['NO'],
onTimeInterestProvisionEnabled: ['NO'],
checkAgainstClearingEnabled: ['NO'],
gstOnPenaltyEnabled: ['NO'],
penaltyInterestGraceDays: [''],
overdrawnInterestRate: [''],
calculateDailyLimitEnhancementTodEnabled: ['NO'],
fixedDepositOverdraftMultipleInterestRateEnabled: ['NO'],
graceDaysAfterOverdueForMoratorium: [''],
// Credit Bureau Settings (CIBIL)
cibilAccountType: [''],
@@ -496,60 +426,7 @@ export class LoanProductSettingsStepComponent implements OnInit {
isFloatingRate: [false],
foreclosureChargesApplicable: ['NO'],
keyFactStatementEnabled: [true],
annualPercentageRateCalculationEnabled: [true],
// Channel & Transaction Limits
isMobileAppEnabled: ['NO'],
isAtmEnabled: ['NO'],
atmYearlyChargeEnabled: ['NO'],
atmMinimumBalance: [''],
emailActiveEnabled: ['NO'],
isImpsEnabled: ['NO'],
isUpiEnabled: ['NO'],
isPosEnabled: ['NO'],
isBbpsEnabled: ['NO'],
interBankLimitPerDay: [''],
interBankLimitPerTransaction: [''],
neftRtgsLimitPerDay: [''],
neftRtgsLimitPerTransaction: [''],
neftCutoffLimit: [''],
rtgsCutoffLimit: [''],
neftRtgsMinimumBalance: [''],
isChargeRequiredNeftRtgsCbs: [''],
isChargeRequiredNeftRtgsMobile: [''],
impsLimitPerDay: [''],
impsLimitPerTransaction: [''],
impsLimitPerMonth: [''],
impsMinLimit: [''],
impsMonthlyTransactionCount: [''],
// POS Facility
maxPerTransactionLimitAtm: [''],
maxDayTransactionLimitAtm: [''],
maxPerTransactionLimitPos: [''],
maxDayTransactionLimitPos: [''],
maxPerTransactionLimitEcom: [''],
maxDayTransactionLimitEcom: [''],
maxPerTransactionLimitUpi: [''],
maxDayTransactionLimitUpi: [''],
// ATM CSV File Related Other Details
atmCsvProductCode: [''],
atmCsvTypeCode: [''],
// Auto Interest Post Parameters
autoInterestPostType: [''],
autoInterestPostWhen: [''],
autoInterestPostFixDay: [''],
autoLastInterestDate: [''],
// Foreclosure / Early Account Closure
calculateEarlyAccountCloseCharge: ['NO'],
earlyAccountCloseCharge: [''],
earlyAccountClosePeriodInMonths: [''],
// Gold Loan Specific
goldLoanPacketNumberWithYearEnabled: ['NO']
annualPercentageRateCalculationEnabled: [true]
});
}
@@ -952,20 +829,10 @@ export class LoanProductSettingsStepComponent implements OnInit {
}
get loanProductSettings() {
const productSettings = { ...this.loanProductSettingsForm.value };
const productSettings = this.loanProductSettingsForm.value;
if (productSettings['delinquencyBucketId'] === '') {
productSettings['delinquencyBucketId'] = null;
}
// `value` omits disabled controls, which would leave a legacy field blank on the preview step
// even though the officer filled it in. Only the custom fields are added back, never the
// standard Fineract ones -- those are disabled deliberately and must stay out of the payload.
// Carrying the custom ones is free: buildPayload() strips every one of them before submitting.
for (const field of CUSTOM_UI_ONLY_FIELDS) {
const control = this.loanProductSettingsForm.get(field);
if (control?.disabled) {
productSettings[field] = control.value;
}
}
return productSettings;
}
@@ -17,69 +17,6 @@
matTooltip="{{ 'tooltips.Fields are used to define the minimum, default, and maximum principal' | translate }}"
></i>
</div>
<div class="lpt-subsection">
<div class="lpt-grid lpt-grid-4">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Minimum' | translate }}</mat-label>
<input type="number" matInput formControlName="minPrincipal" [min]="0" />
<mat-error *ngIf="loanProductTermsForm.get('minPrincipal')?.hasError('min')">
{{ 'labels.commons.Minimum Value must be' | translate }} <strong>0</strong>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Default' | translate }}</mat-label>
<input type="number" matInput formControlName="principal" required [min]="0" />
<mat-error *ngIf="loanProductTermsForm.get('principal')?.hasError('required')">
{{ 'labels.inputs.Principal' | translate }}
<span class="text-danger">{{ 'labels.commons.is required' | translate }}</span>
</mat-error>
<mat-error *ngIf="loanProductTermsForm.get('principal')?.hasError('min')">
{{ 'labels.commons.Minimum Value must be' | translate }} <strong>0</strong>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Maximum' | translate }}</mat-label>
<input type="number" matInput formControlName="maxPrincipal" [min]="0" />
<mat-error *ngIf="loanProductTermsForm.get('maxPrincipal')?.hasError('min')">
{{ 'labels.commons.Minimum Value must be' | translate }} <strong>0</strong>
</mat-error>
</mat-form-field>
</div>
</div>
<div class="lpt-subsection">
<h4 class="lpt-subsection-title">
{{ 'labels.inputs.Number of Repayments' | translate }}
</h4>
<div class="lpt-grid lpt-grid-4">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Minimum' | translate }}</mat-label>
<input type="number" matInput formControlName="minNumberOfRepayments" [min]="1" />
<mat-error *ngIf="loanProductTermsForm.get('minNumberOfRepayments')?.hasError('min')">
{{ 'labels.commons.Minimum Value must be' | translate }} <strong>1</strong>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Default' | translate }}</mat-label>
<input type="number" matInput formControlName="numberOfRepayments" required [min]="1" />
<mat-error *ngIf="loanProductTermsForm.get('numberOfRepayments')?.hasError('required')">
{{ 'labels.inputs.Number of Repayments' | translate }}
<span class="text-danger">{{ 'labels.commons.is required' | translate }}</span>
</mat-error>
<mat-error *ngIf="loanProductTermsForm.get('numberOfRepayments')?.hasError('min')">
{{ 'labels.commons.Minimum Value must be' | translate }} <strong>1</strong>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Maximum' | translate }}</mat-label>
<input type="number" matInput formControlName="maxNumberOfRepayments" [min]="1" />
<mat-error *ngIf="loanProductTermsForm.get('maxNumberOfRepayments')?.hasError('min')">
{{ 'labels.commons.Minimum Value must be' | translate }} <strong>1</strong>
</mat-error>
</mat-form-field>
</div>
</div>
<div class="lpt-grid">
<div class="lpt-checkbox-row lpt-full-width">
<mat-checkbox labelPosition="before" formControlName="allowApprovedDisbursedAmountsOverApplied">
@@ -327,56 +264,90 @@
}
</div>
<!-- Interest Rate Detail Section (legacy Page 1 table) -->
<!-- Interest Rate Slabs Section -->
<div class="lpt-section">
<div class="lpt-section-header">
<i class="fas fa-table lpt-section-icon"></i>
<h3>Interest Rate Detail</h3>
<h3>Interest Rate Slabs</h3>
</div>
<div class="lpt-ird-table-wrap">
<table class="lpt-ird-table">
<thead>
<tr>
<th rowspan="2">Interest Type</th>
<th rowspan="2">Calculation</th>
<th colspan="2">Normal Category</th>
<th colspan="2">Special Category</th>
<th rowspan="2">Period</th>
</tr>
<tr>
<th>Mini. Rate</th>
<th>Maxi. Rate</th>
<th>Mini. Rate</th>
<th>Maxi. Rate</th>
</tr>
</thead>
<tbody>
@for (row of interestRateDetailGroups; track $index) {
<tr [formGroup]="row">
<td class="lpt-ird-type">{{ interestRateDetailTypes[$index] }}</td>
<td>
<select formControlName="calculation" class="lpt-ird-select">
<option [ngValue]="true">YES</option>
<option [ngValue]="false">NO</option>
</select>
</td>
<td><input type="number" formControlName="normalMinRate" class="lpt-ird-input" min="0" max="100" /></td>
<td><input type="number" formControlName="normalMaxRate" class="lpt-ird-input" min="0" max="100" /></td>
<td><input type="number" formControlName="specialMinRate" class="lpt-ird-input" min="0" max="100" /></td>
<td><input type="number" formControlName="specialMaxRate" class="lpt-ird-input" min="0" max="100" /></td>
<td>
<select formControlName="period" class="lpt-ird-select">
<option value=""></option>
@for (period of interestRateDetailPeriods; track period) {
<option [value]="period">{{ period }}</option>
}
</select>
</td>
</tr>
}
</tbody>
</table>
<div class="lpt-slab-c">
<!-- Particular Name -->
<div class="lpt-slab-c-particular">
<label class="lpt-slab-c-particular-label">Particular</label>
<input
type="text"
class="lpt-slab-c-name-input"
[formControl]="particularLabel"
placeholder="e.g. Consumer Loan"
/>
</div>
<!-- 2-column slab grid (same as before, General + Special inside each card) -->
<div class="lpt-slab-c-grid">
@for (genGroup of slabGroups; track $index) {
<div class="lpt-slab-c-item">
<span class="lpt-slab-c-range">{{ interestSlabDefinitions[$index].label }}</span>
<div class="lpt-slab-c-rate-pair">
<!-- General -->
<div class="lpt-slab-c-rate-field" [formGroup]="genGroup">
<span class="lpt-slab-c-rate-label">Gen</span>
<div class="lpt-slab-c-input-wrap">
<input
type="number"
class="lpt-slab-c-input"
formControlName="interestRate"
placeholder="0.00"
min="0"
max="100"
step="0.01"
/>
<span class="lpt-slab-c-suffix">%</span>
</div>
</div>
<!-- Special -->
<div class="lpt-slab-c-rate-field" [formGroup]="specialSlabGroups[$index]">
<span class="lpt-slab-c-rate-label lpt-slab-c-rate-label--special">Spl</span>
<div class="lpt-slab-c-input-wrap">
<input
type="number"
class="lpt-slab-c-input lpt-slab-c-input--special"
formControlName="interestRate"
placeholder="0.00"
min="0"
max="100"
step="0.01"
/>
<span class="lpt-slab-c-suffix">%</span>
</div>
</div>
</div>
</div>
}
</div>
<!-- Fixed Interest Rate row -->
<div class="lpt-slab-c-fixed" [formGroup]="fixedRateGroup">
<label class="lpt-slab-c-fixed-toggle">
<input type="checkbox" formControlName="hasFixed" class="lpt-slab-c-checkbox" />
<i class="fas fa-lock"></i>
<span>Fixed Interest Rate (%)</span>
</label>
@if (fixedRateGroup.get('hasFixed').value) {
<div class="lpt-slab-c-input-wrap">
<input
type="number"
class="lpt-slab-c-input lpt-slab-c-input--fixed"
formControlName="fixedAmount"
placeholder="0.00"
min="0"
max="100"
step="0.01"
/>
<span class="lpt-slab-c-suffix">%</span>
</div>
}
</div>
</div>
</div>
@@ -199,42 +199,175 @@ $shadow-hover: 0 4px 12px rgb(37 99 235 / 10%);
padding: 8px 4px;
}
// ─── Interest Rate Detail table (legacy Page 1 grid) ─────────────────────────
.lpt-ird-table-wrap {
// ─── Interest Rate Slabs — Option C (2-column grid) ──────────────────────────
.lpt-slab-c {
padding: 16px 24px 20px;
overflow-x: auto;
display: flex;
flex-direction: column;
gap: 0;
}
.lpt-ird-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
th,
td {
border: 1px solid $border;
padding: 6px 8px;
text-align: center;
}
thead th {
background: $surface-alt;
font-weight: 700;
color: $text-secondary;
text-transform: uppercase;
letter-spacing: 0.03em;
font-size: 11px;
}
// Particular row
.lpt-slab-c-particular {
display: flex;
align-items: center;
gap: 14px;
padding-bottom: 14px;
border-bottom: 1px solid $border;
margin-bottom: 12px;
flex-wrap: wrap;
}
.lpt-ird-type {
text-align: left !important;
font-weight: 600;
color: $text-primary;
.lpt-slab-c-particular-label {
font-size: 12px;
font-weight: 700;
color: $text-secondary;
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
}
.lpt-ird-input {
.lpt-slab-c-name-input {
flex: 1;
max-width: 320px;
padding: 7px 12px;
border: 1px solid $border;
border-radius: 8px;
font-size: 13.5px;
font-weight: 600;
color: $text-primary;
background: $surface;
outline: none;
transition:
border-color 0.15s,
box-shadow 0.15s;
&::placeholder {
color: #b0b8c4;
font-weight: 400;
}
&:focus {
border-color: $primary;
box-shadow: 0 0 0 3px rgb(37 99 235 / 10%);
}
}
// General / Special tab toggle
.lpt-slab-tabs {
display: flex;
gap: 6px;
margin-left: auto;
}
.lpt-slab-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 16px;
border: 1.5px solid $border;
border-radius: 20px;
background: $surface;
font-size: 12.5px;
font-weight: 600;
color: $text-secondary;
cursor: pointer;
transition: all 0.15s;
i {
font-size: 11px;
}
&:hover {
border-color: $primary;
color: $primary;
}
&--active {
background: $primary;
border-color: $primary;
color: $surface;
box-shadow: 0 2px 8px rgb(37 99 235 / 25%);
}
}
// 2-column slab grid
.lpt-slab-c-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 16px;
@media (width <= 540px) {
grid-template-columns: 1fr;
}
}
.lpt-slab-c-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 12px;
border: 1px solid $border;
border-radius: 8px;
background: $surface;
transition:
border-color 0.15s,
box-shadow 0.15s;
&:focus-within {
border-color: $primary;
box-shadow: 0 0 0 3px rgb(37 99 235 / 8%);
}
}
// General + Special pair inside each card
.lpt-slab-c-rate-pair {
display: flex;
gap: 8px;
align-items: center;
}
.lpt-slab-c-rate-field {
display: flex;
align-items: center;
gap: 4px;
}
.lpt-slab-c-rate-label {
font-size: 10px;
font-weight: 700;
color: $primary;
background: #eff6ff;
padding: 2px 5px;
border-radius: 4px;
white-space: nowrap;
&--special {
color: #92400e;
background: #fef9c3;
}
}
.lpt-slab-c-range {
font-size: 12.5px;
font-weight: 500;
color: $text-label;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// Input + suffix wrapper
.lpt-slab-c-input-wrap {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.lpt-slab-c-input {
width: 70px;
padding: 4px 6px;
border: 1px solid $border;
@@ -246,26 +379,79 @@ $shadow-hover: 0 4px 12px rgb(37 99 235 / 10%);
background: $surface-alt;
outline: none;
transition: border-color 0.15s;
appearance: textfield;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
appearance: none;
}
&:focus {
border-color: $primary;
background: $primary-light;
}
}
.lpt-ird-select {
width: 100%;
min-width: 90px;
padding: 4px 6px;
border: 1px solid $border;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
color: $text-primary;
background: $surface;
outline: none;
&:focus {
border-color: $primary;
&--fixed {
width: 90px;
}
}
.lpt-slab-c-suffix {
font-size: 12px;
font-weight: 700;
color: $text-secondary;
user-select: none;
&--special {
color: #92400e;
}
}
.lpt-slab-c-input--special {
&:focus {
border-color: #f59e0b;
background: #fef9c3;
}
}
.lpt-slab-c-error {
font-size: 10px;
color: #ef4444;
}
// Fixed Interest Rate row (below grid)
.lpt-slab-c-fixed {
display: flex;
align-items: center;
gap: 20px;
margin: 12px -24px -20px;
padding: 12px 16px;
border-top: 2px dashed $primary-border;
background: $primary-light;
border-radius: 0 0 $radius-card $radius-card;
padding-left: 24px;
padding-right: 24px;
}
.lpt-slab-c-fixed-toggle {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
color: $primary;
user-select: none;
i {
font-size: 12px;
opacity: 0.7;
}
}
.lpt-slab-c-checkbox {
accent-color: $primary;
width: 15px;
height: 15px;
cursor: pointer;
}
@@ -17,7 +17,6 @@ import {
FormControl,
ReactiveFormsModule
} from '@angular/forms';
import { patchCustomFields } from '../../loan-product-custom-fields';
import { ProcessingStrategyService } from '../../services/processing-strategy.service';
import { TranslateService } from '@ngx-translate/core';
import { MatTooltip } from '@angular/material/tooltip';
@@ -58,23 +57,24 @@ export class LoanProductTermsStepComponent implements OnInit, OnChanges {
repaymentFrequencyTypeData: any;
repaymentStartDateTypeOptions: any;
// Legacy "Interest Rate Detail" table — 4 interest types x Normal/Special category x Min/Max Rate x Period
readonly interestRateDetailTypes = [
'Normal Debit',
'Normal Credit',
'Against Clearing',
'Penalty'
readonly interestSlabDefinitions = [
{ label: 'Upto 10,000', maxAmount: 10000 },
{ label: 'Upto 25,000', maxAmount: 25000 },
{ label: 'Upto 1,00,000', maxAmount: 100000 },
{ label: 'Upto 2,00,000', maxAmount: 200000 },
{ label: 'Upto 5,00,000', maxAmount: 500000 },
{ label: 'Upto 10,00,000', maxAmount: 1000000 },
{ label: 'Upto 15,00,000', maxAmount: 1500000 },
{ label: 'Upto 20,00,000', maxAmount: 2000000 },
{ label: 'Upto 25,00,000', maxAmount: 2500000 },
{ label: 'More than 25,00,000', maxAmount: null }
];
readonly interestRateDetailPeriods = [
'DAILY',
'MONTHLY',
'QUARTERLY',
'HALF YEARLY',
'YEARLY'
];
interestRateDetailArray: FormArray;
interestRateSlabsArray: FormArray;
specialSlabsArray: FormArray;
fixedRateGroup: FormGroup;
particularLabel = new FormControl('');
activeSlabTab: 'general' | 'special' = 'general';
constructor() {
this.createLoanProductTermsForm();
@@ -92,12 +92,6 @@ export class LoanProductTermsStepComponent implements OnInit, OnChanges {
];
this.loanProductTermsForm.patchValue({
minPrincipal: this.loanProductsTemplate.minPrincipal,
principal: this.loanProductsTemplate.principal,
maxPrincipal: this.loanProductsTemplate.maxPrincipal,
minNumberOfRepayments: this.loanProductsTemplate.minNumberOfRepayments,
numberOfRepayments: this.loanProductsTemplate.numberOfRepayments,
maxNumberOfRepayments: this.loanProductsTemplate.maxNumberOfRepayments,
isLinkedToFloatingInterestRates: this.loanProductsTemplate.isLinkedToFloatingInterestRates,
minInterestRatePerPeriod: this.loanProductsTemplate.minInterestRatePerPeriod,
interestRatePerPeriod: this.loanProductsTemplate.interestRatePerPeriod,
@@ -127,11 +121,6 @@ export class LoanProductTermsStepComponent implements OnInit, OnChanges {
});
}
// Repopulate the legacy "Account Type Creation" fields saved against this product.
// Last, so a value actually stored wins over any default set above.
patchCustomFields(this.loanProductTermsForm, this.loanProductsTemplate);
this.patchInterestRateDetail();
this.zeroInterest.patchValue(
this.loanProductsTemplate.minInterestRatePerPeriod === 0 &&
this.loanProductsTemplate.interestRatePerPeriod === 0 &&
@@ -145,63 +134,36 @@ export class LoanProductTermsStepComponent implements OnInit, OnChanges {
}
createLoanProductTermsForm() {
this.interestRateDetailArray = new FormArray(
this.interestRateDetailTypes.map(
this.interestRateSlabsArray = new FormArray(
this.interestSlabDefinitions.map(
() =>
new FormGroup({
calculation: new FormControl(false),
normalMinRate: new FormControl(null, [
interestRate: new FormControl(null, [
Validators.min(0),
Validators.max(100)
]),
normalMaxRate: new FormControl(null, [
Validators.min(0),
Validators.max(100)
]),
specialMinRate: new FormControl(null, [
Validators.min(0),
Validators.max(100)
]),
specialMaxRate: new FormControl(null, [
Validators.min(0),
Validators.max(100)
]),
period: new FormControl('')
])
})
)
);
this.specialSlabsArray = new FormArray(
this.interestSlabDefinitions.map(
() =>
new FormGroup({
interestRate: new FormControl(null, [
Validators.min(0),
Validators.max(100)
])
})
)
);
this.fixedRateGroup = new FormGroup({
hasFixed: new FormControl(false),
fixedAmount: new FormControl({ value: null, disabled: true }, [Validators.min(0)])
});
this.loanProductTermsForm = this.formBuilder.group({
minPrincipal: [
'',
[Validators.min(0)]
],
principal: [
'',
[
Validators.required,
Validators.min(0)
]
],
maxPrincipal: [
'',
[Validators.min(0)]
],
minNumberOfRepayments: [
'',
[Validators.min(1)]
],
numberOfRepayments: [
'',
[
Validators.required,
Validators.min(1)
]
],
maxNumberOfRepayments: [
'',
[Validators.min(1)]
],
isLinkedToFloatingInterestRates: [false],
allowApprovedDisbursedAmountsOverApplied: [false],
overAppliedCalculationType: [{ value: null, disabled: true }],
@@ -325,6 +287,15 @@ export class LoanProductTermsStepComponent implements OnInit, OnChanges {
}
});
this.fixedRateGroup.get('hasFixed').valueChanges.subscribe((hasFixed: boolean) => {
if (hasFixed) {
this.fixedRateGroup.get('fixedAmount').enable();
} else {
this.fixedRateGroup.get('fixedAmount').disable();
this.fixedRateGroup.get('fixedAmount').setValue(null);
}
});
this.zeroInterest.valueChanges.subscribe((zeroInterest) => {
if (zeroInterest) {
this.loanProductTermsForm.get('minInterestRatePerPeriod').patchValue(0);
@@ -351,34 +322,16 @@ export class LoanProductTermsStepComponent implements OnInit, OnChanges {
});
}
get interestRateDetailGroups(): FormGroup[] {
return this.interestRateDetailArray.controls as FormGroup[];
get slabGroups(): FormGroup[] {
return this.interestRateSlabsArray.controls as FormGroup[];
}
/**
* Fills the legacy Interest Rate Detail grid from a saved product. Rows are matched on
* `interestType` rather than by position, so a payload that comes back in a different order cannot
* put one interest type's rates onto another. A type with no saved row is left at its defaults.
*/
private patchInterestRateDetail(): void {
const saved: any[] = this.loanProductsTemplate?.interestRateDetail;
if (!Array.isArray(saved) || saved.length === 0) {
return;
}
this.interestRateDetailTypes.forEach((interestType, index) => {
const row = saved.find((r) => r?.interestType === interestType);
if (!row) {
return;
}
this.interestRateDetailArray.at(index).patchValue({
calculation: row.calculation ?? false,
normalMinRate: row.normalMinRate,
normalMaxRate: row.normalMaxRate,
specialMinRate: row.specialMinRate,
specialMaxRate: row.specialMaxRate,
period: row.period ?? ''
});
});
get activeSlabGroups(): FormGroup[] {
return this.interestRateSlabsArray.controls as FormGroup[];
}
get specialSlabGroups(): FormGroup[] {
return this.specialSlabsArray.controls as FormGroup[];
}
get loanProductTerms() {
@@ -396,10 +349,15 @@ export class LoanProductTermsStepComponent implements OnInit, OnChanges {
minInterestRatePerPeriod: normalizeDecimal(formValue.minInterestRatePerPeriod),
interestRatePerPeriod: normalizeDecimal(formValue.interestRatePerPeriod),
maxInterestRatePerPeriod: normalizeDecimal(formValue.maxInterestRatePerPeriod),
interestRateDetail: this.interestRateDetailArray.getRawValue().map((row, i) => ({
interestType: this.interestRateDetailTypes[i],
...row
}))
interestRateSlabs: this.interestRateSlabsArray.getRawValue().map((slab, i) => ({
maxAmount: this.interestSlabDefinitions[i].maxAmount,
label: this.interestSlabDefinitions[i].label,
interestRate: slab.interestRate
})),
fixedRate: (() => {
const fr = this.fixedRateGroup.getRawValue();
return fr.hasFixed ? fr.fixedAmount : null;
})()
};
}
@@ -9,10 +9,6 @@
import { Injectable, inject } from '@angular/core';
import { SettingsService } from 'app/settings/settings.service';
import { CUSTOM_DATE_FIELDS, CUSTOM_YES_NO_STRING_FIELDS } from './loan-product-custom-fields';
export { CUSTOM_UI_ONLY_FIELDS } from './loan-product-custom-fields';
@Injectable({
providedIn: 'root'
})
@@ -39,42 +35,6 @@ export class LoanProducts {
return loanProductTemplate;
}
/**
* The backend returns the legacy "Account Type Creation" fields nested under `cbsExtensions`, while
* every step form reads and writes them as flat properties on the product. Lifting them here keeps
* that difference in one place rather than teaching each step about the nesting.
*
* Two shapes also have to be converted back to what the form controls expect:
*
* - the yes/no dropdowns hold the strings 'YES'/'NO', but the column is BOOLEAN, so the API returns
* true/false -- patched as-is the control matches no option and shows blank;
* - a date arrives as Fineract's [year, month, day] array rather than a Date.
*
* Mutates and returns the product so it can be dropped into an existing resolver chain.
*/
public flattenCbsExtensions(loanProduct: any): any {
if (!loanProduct?.cbsExtensions) {
return loanProduct;
}
Object.assign(loanProduct, loanProduct.cbsExtensions);
for (const field of CUSTOM_YES_NO_STRING_FIELDS) {
const value = loanProduct[field];
if (typeof value === 'boolean') {
loanProduct[field] = value ? 'YES' : 'NO';
}
}
for (const field of CUSTOM_DATE_FIELDS) {
const value = loanProduct[field];
if (Array.isArray(value) && value.length >= 3) {
loanProduct[field] = new Date(value[0], value[1] - 1, value[2]);
}
}
return loanProduct;
}
public buildPayload(loanProductData: any, itemsByDefault: any) {
const dateFormat: string = this.settingsService.dateFormat;
const locale: string = this.settingsService.language.code;
@@ -89,12 +49,6 @@ export class LoanProducts {
delete loanProduct.allowAttributeConfiguration;
delete loanProduct.advancedAccountingRules;
// The legacy "Account Type Creation" fields used to be stripped here, because Fineract's loan
// product validator rejected any parameter it did not recognise. They are now persisted by
// LoanProductCbsExtService in a satellite table keyed on the product, so they are sent as-is:
//
// CUSTOM_UI_ONLY_FIELDS.forEach((field) => delete loanProduct[field]);
// In Fineract, the POST and PUT endpoints for /v1/loanproducts have a typo in the field
// allowPartialPeriodInterestCalculation. Until that is fixed, we need to replace the field name in the payload.
loanProduct.allowPartialPeriodInterestCalculation = loanProduct.allowPartialPeriodInterestCalculation;
@@ -36,7 +36,7 @@
</div>
<div class="meta-item">
<span class="meta-label">Account No.</span>
<span class="meta-value">{{ accountData.accountNo }}</span>
<span class="meta-value">{{ accountData.fdAccountNumber || accountData.accountNo }}</span>
</div>
<div class="meta-item">
<span class="meta-label">Date of Issue</span>
@@ -49,9 +49,13 @@
This is to certify that
<strong class="highlight-text">{{ accountData.clientName || accountData.groupName }}</strong>
holds a {{ accountType === 'FD' ? 'Fixed Deposit' : 'Recurring Deposit' }} account bearing account number
<strong class="highlight-text">{{ accountData.accountNo }}</strong>
<strong class="highlight-text">{{ accountData.fdAccountNumber || accountData.accountNo }}</strong>
with <strong class="highlight-text">Nexora Co-operative Bank Ltd.</strong>, as per the particulars set out
below.
<ng-container *ngIf="renewalCount">
This account was renewed on maturity {{ renewalCount }} {{ renewalCount === 1 ? 'time' : 'times' }}; the
full renewal history is set out below.
</ng-container>
</p>
<div class="issued-to-details">
@@ -129,6 +133,44 @@
</tbody>
</table>
<!-- Renewal History -- one row per term of this FD, oldest first. Deliberately shows only the
term number, not each term's internal account number: the customer holds one FD under one
number, and listing the per-term account numbers here would read as several deposits. Staff
can still see them on the account view's own renewal-history card. -->
<table class="certificate-details-table renewal-history-table" *ngIf="renewalHistory.length">
<thead>
<tr>
<th colspan="7" class="renewal-history-title">RENEWAL HISTORY</th>
</tr>
<tr>
<th>Term</th>
<th>From</th>
<th>To</th>
<th>Rate</th>
<th>Principal</th>
<th>Interest</th>
<th>Maturity Amount</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let term of renewalHistory" [class.current-term-row]="term.accountid === accountData.id">
<td>{{ term.renewalcycleno === 0 ? 'Original' : 'Renewal ' + term.renewalcycleno }}</td>
<td>{{ term.activatedondate | dateFormat }}</td>
<td>{{ term.maturitydate | dateFormat }}</td>
<td>{{ term.interestrate }}%</td>
<td>{{ term.depositamount | currency: accountData.currency.code : 'symbol-narrow' }}</td>
<td>
{{
term.interestearned === null || term.interestearned === undefined
? '—'
: (term.interestearned | currency: accountData.currency.code : 'symbol-narrow')
}}
</td>
<td>{{ maturityFor(term) | currency: accountData.currency.code : 'symbol-narrow' }}</td>
</tr>
</tbody>
</table>
<div class="cert-footer">
<div class="seal-container">
<div class="seal-ring">
@@ -250,6 +250,32 @@
}
}
/* Renewal History Table -- lists each earlier term this FD was renewed from */
.renewal-history-table {
th {
padding: 8px 10px;
border: 1px solid #ede8db;
font-size: 11px;
text-align: left;
background-color: #f7f5ee;
color: #7b6f63;
}
.renewal-history-title {
text-align: center;
background-color: #f5f2e9;
color: #0b3c5d;
font-weight: bold;
letter-spacing: 1.5px;
font-size: 12px;
}
.current-term-row {
background-color: #fbfaf5;
font-weight: bold;
}
}
/* Certificate Footer Seal / Signatures */
.cert-footer {
display: flex;
@@ -485,6 +511,24 @@
}
}
.renewal-history-table {
margin-bottom: 12px !important;
th {
padding: 4px 8px !important;
font-size: 8px !important;
}
td {
padding: 4px 8px !important;
font-size: 9px !important;
}
.renewal-history-title {
font-size: 10px !important;
}
}
.cert-footer {
margin-top: 8px !important;
@@ -60,12 +60,29 @@ export class DepositCertificateDialogComponent implements OnInit {
certificateNo = '';
issueDate: Date = new Date();
/**
* Every term of this FD, oldest first, as resolved by the backend -- a renewal opens each term as
* its own account, so the certificate lists them all to show the deposit's full history. Empty for
* an FD that has never been renewed. Keys are lower-cased, arriving from SQL column labels.
*/
renewalHistory: any[] = [];
/**
* How many times the deposit has actually rolled over. {@link renewalHistory} lists every TERM, and the
* first of those is the original deposit rather than a renewal, so the count is one less than the number
* of rows. Stated on a customer-facing certificate, so an off-by-one here misreports the deposit.
*/
get renewalCount(): number {
return Math.max(this.renewalHistory.length - 1, 0);
}
ngOnInit() {
this.accountType = this.data.accountType;
this.accountData = this.data.accountData;
const clientId = this.data.clientId != null ? String(this.data.clientId) : null;
this.certificateNo = `${this.accountType}-CERT-${this.accountData.accountNo}`;
this.certificateNo = `${this.accountType}-CERT-${this.accountData.fdAccountNumber || this.accountData.accountNo}`;
this.renewalHistory = this.accountData?.renewalHistory ?? [];
if (!clientId) {
return;
@@ -100,6 +117,14 @@ export class DepositCertificateDialogComponent implements OnInit {
});
}
/**
* A term's settled maturity amount, or its projection while it is still running. Resolved here rather
* than inline so the choice isn't left to operator precedence between `??` and the currency pipe.
*/
maturityFor(term: any): number | null {
return term?.maturedamount ?? term?.projectedmaturityamount ?? null;
}
formatAddress(addr: any): string {
if (!addr) return '';
return [
@@ -49,8 +49,13 @@
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let edit">
<button mat-stroked-button class="psp-btn psp-btn-view" (click)="viewDiff(edit)">
<fa-icon icon="eye" class="m-r-10"></fa-icon>Compare
<button
mat-stroked-button
class="psp-btn psp-btn-view"
[disabled]="loadingDiffFor === edit"
(click)="viewDiff(edit)"
>
<fa-icon icon="eye" class="m-r-10"></fa-icon>{{ loadingDiffFor === edit ? 'Loading…' : 'Compare' }}
</button>
<button mat-stroked-button class="psp-btn psp-btn-approve" (click)="approve(edit)">
<fa-icon icon="check" class="m-r-10"></fa-icon>Approve
@@ -65,37 +70,6 @@
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row"></tr>
</table>
</div>
@if (selectedEdit) {
<div class="psp-diff-panel">
<div class="psp-diff-header">
<h3>Comparing "{{ selectedEdit.savingsProductName }}" &mdash; Original vs Proposed</h3>
<button mat-icon-button (click)="closeDiff()"><fa-icon icon="times"></fa-icon></button>
</div>
@if (diffRows.length === 0) {
<p class="psp-diff-loading">Loading comparison&hellip;</p>
} @else {
<table class="psp-diff-table">
<thead>
<tr>
<th>Field</th>
<th>Original</th>
<th>Proposed</th>
</tr>
</thead>
<tbody>
@for (row of diffRows; track row.label) {
<tr [class.psp-diff-row--changed]="row.changed">
<td class="psp-diff-label">{{ row.label }}</td>
<td class="psp-diff-original">{{ row.original }}</td>
<td class="psp-diff-proposed">{{ row.proposed }}</td>
</tr>
}
</tbody>
</table>
}
</div>
}
}
@if (!showData) {
@@ -87,64 +87,6 @@ $text-muted: #64748b;
}
}
.psp-diff-panel {
margin-top: 20px;
border: 1px solid $border;
border-radius: 12px;
background: #fff;
padding: 16px;
}
.psp-diff-header {
display: flex;
align-items: center;
justify-content: space-between;
h3 {
margin: 0;
font-size: 14px;
font-weight: 700;
}
}
.psp-diff-loading {
color: $text-muted;
font-size: 13px;
}
.psp-diff-table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
font-size: 12.5px;
th,
td {
text-align: left;
padding: 8px 10px;
border-bottom: 1px solid $border;
}
th {
color: $text-muted;
font-weight: 600;
}
}
.psp-diff-row--changed {
background: #fffbeb;
.psp-diff-original {
color: #b91c1c;
text-decoration: line-through;
}
.psp-diff-proposed {
color: #15803d;
font-weight: 600;
}
}
.empty-state {
display: flex;
flex-direction: column;
@@ -23,10 +23,10 @@ import {
} from '@angular/material/table';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatIconButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { ProductsService } from 'app/products/products.service';
import { ProductEditDiffDialogComponent } from './product-edit-diff-dialog/product-edit-diff-dialog.component';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
@@ -50,8 +50,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatHeaderRow,
MatRowDef,
MatRow,
MatIcon,
MatIconButton
MatIcon
]
})
export class PendingSavingsProductsComponent {
@@ -72,9 +71,8 @@ export class PendingSavingsProductsComponent {
'actions'
];
selectedEdit: any = null;
originalProduct: any = null;
diffRows: { label: string; original: any; proposed: any; changed: boolean }[] = [];
/** Set only while the original product is being fetched, so the button can show progress. */
loadingDiffFor: any = null;
constructor() {
this.route.data.subscribe((data: { pendingSavingsProductsData: any }) => {
@@ -96,18 +94,23 @@ export class PendingSavingsProductsComponent {
return isNaN(d.getTime()) ? String(date) : d.toLocaleString('en-IN');
}
/**
* Fetched with template=true so the response carries the option lists (accountingRuleOptions,
* interestCompoundingPeriodTypeOptions, ...) alongside the product. The submitted edit stores enum
* fields as bare ids, so the diff needs those lists to show a readable name instead of a raw number.
*/
private getOriginalProduct$(edit: any) {
switch (edit.productType) {
case 'CURRENT':
return this.productsService.getCurrentProduct(edit.savingsProductId);
return this.productsService.getCurrentProduct(edit.savingsProductId, true);
case 'SHARE':
return this.productsService.getShareProduct(edit.savingsProductId);
return this.productsService.getShareProduct(edit.savingsProductId, true);
case 'FD':
return this.productsService.getFixedDepositProduct(edit.savingsProductId);
return this.productsService.getFixedDepositProductAndTemplate(edit.savingsProductId);
case 'RD':
return this.productsService.getRecurringDepositProduct(edit.savingsProductId);
return this.productsService.getRecurringDepositProductAndTemplate(edit.savingsProductId);
default:
return this.productsService.getSavingProduct(edit.savingsProductId);
return this.productsService.getSavingProduct(edit.savingsProductId, true);
}
}
@@ -142,29 +145,27 @@ export class PendingSavingsProductsComponent {
}
viewDiff(edit: any): void {
this.selectedEdit = edit;
this.diffRows = [];
this.getOriginalProduct$(edit).subscribe((original: any) => {
this.originalProduct = original;
const proposed = edit.proposedChanges || {};
const keys = Object.keys(proposed);
this.diffRows = keys.map((key) => {
const originalValue = original ? original[key] : undefined;
const proposedValue = proposed[key];
return {
label: key,
original: originalValue,
proposed: proposedValue,
changed: JSON.stringify(originalValue) !== JSON.stringify(proposedValue)
};
});
this.loadingDiffFor = edit;
this.getOriginalProduct$(edit).subscribe({
next: (original: any) => {
this.loadingDiffFor = null;
this.dialog.open(ProductEditDiffDialogComponent, {
width: '820px',
data: {
productName: edit.savingsProductName,
original: original ?? {},
proposed: edit.proposedChanges ?? {}
}
});
},
error: () => {
this.loadingDiffFor = null;
}
});
}
closeDiff(): void {
this.selectedEdit = null;
this.originalProduct = null;
this.diffRows = [];
this.loadingDiffFor = null;
}
approve(edit: any): void {
@@ -0,0 +1,53 @@
<!--
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/.
-->
<h2 mat-dialog-title class="ped-title">
<mat-icon class="ped-title__icon">difference</mat-icon>
<span>
Proposed changes &mdash; <strong>{{ productName }}</strong>
</span>
</h2>
<div mat-dialog-content class="ped-content">
@if (rows.length === 0) {
<div class="ped-empty">
<mat-icon class="ped-empty__icon">check_circle</mat-icon>
<p class="ped-empty__title">No changes to review</p>
<p class="ped-empty__text">
This edit was submitted without changing any product setting &mdash; every submitted value matches the current
product.
</p>
</div>
} @else {
<p class="ped-summary">
<strong>{{ rows.length }}</strong> field{{ rows.length === 1 ? '' : 's' }} changed. Unchanged fields are hidden.
</p>
<table class="ped-table">
<thead>
<tr>
<th>Field</th>
<th>Current</th>
<th>Proposed</th>
</tr>
</thead>
<tbody>
@for (row of rows; track row.label) {
<tr>
<td class="ped-table__label">{{ row.label }}</td>
<td class="ped-table__old">{{ row.original }}</td>
<td class="ped-table__new">{{ row.proposed }}</td>
</tr>
}
</tbody>
</table>
}
</div>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>{{ 'labels.buttons.Close' | translate }}</button>
</mat-dialog-actions>
@@ -0,0 +1,115 @@
/**
* 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/.
*/
$border: #e2e8f0;
$muted: #64748b;
.ped-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
color: #0f172a;
border-bottom: 1px solid $border;
padding-bottom: 12px;
margin-bottom: 0;
&__icon {
color: #0ea5e9;
}
}
.ped-content {
max-height: 70vh;
overflow-y: auto;
padding-top: 16px;
}
.ped-summary {
margin: 0 0 14px;
font-size: 12.5px;
color: $muted;
}
.ped-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
th,
td {
text-align: left;
padding: 9px 12px;
border-bottom: 1px solid $border;
vertical-align: top;
}
th {
color: $muted;
font-weight: 600;
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.4px;
background: #f8fafc;
position: sticky;
top: 0;
}
tbody tr:hover {
background: #fffbeb;
}
&__label {
font-weight: 600;
color: #1e293b;
width: 34%;
}
&__old {
color: #b91c1c;
text-decoration: line-through;
width: 33%;
}
&__new {
color: #15803d;
font-weight: 600;
width: 33%;
}
}
.ped-empty {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 32px 16px;
&__icon {
font-size: 40px;
width: 40px;
height: 40px;
color: #16a34a;
margin-bottom: 10px;
}
&__title {
margin: 0 0 6px;
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
&__text {
margin: 0;
font-size: 12.5px;
color: $muted;
max-width: 420px;
}
}
@@ -0,0 +1,261 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogRef,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
interface DiffRow {
label: string;
original: string;
proposed: string;
}
/**
* Shows what a pending product edit actually changes: only the fields whose value differs, with the
* original and proposed values side by side. The submitted payload carries every field the edit form
* posted, most of them unchanged, so listing all of them buries the handful a checker needs to look at.
*/
@Component({
selector: 'mifosx-product-edit-diff-dialog',
templateUrl: './product-edit-diff-dialog.component.html',
styleUrls: ['./product-edit-diff-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose,
MatButton,
MatIcon
]
})
export class ProductEditDiffDialogComponent implements OnInit {
dialogRef = inject<MatDialogRef<ProductEditDiffDialogComponent>>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
productName = '';
rows: DiffRow[] = [];
/** Request plumbing the edit form has to send, but which isn't a product setting a checker reviews. */
private static readonly IGNORED_KEYS = new Set([
'locale',
'dateFormat',
'monthDayFormat',
'id',
'productId',
'savingsProductId'
]);
/**
* Payload fields the stored product exposes under a different path, so the "current" side has
* something to compare against instead of always reading as newly set.
*/
private static readonly FIELD_ALIASES: Record<string, string> = {
currencyCode: 'currency.code',
digitsAfterDecimal: 'currency.decimalPlaces',
inMultiplesOf: 'currency.inMultiplesOf',
taxGroupId: 'taxGroup.id'
};
/**
* GL-account fields, keyed by their payload name, mapped to where the stored product keeps the
* expanded account under `accountingMappings`. Their option lists live pooled under
* `accountingMappingOptions` rather than a per-field `<field>Options` key, so both sides of the
* diff need this table to turn an account id into the account's name.
*/
private static readonly GL_FIELD_TO_MAPPING: Record<string, string> = {
savingsReferenceAccountId: 'savingsReferenceAccount',
savingsControlAccountId: 'savingsControlAccount',
transfersInSuspenseAccountId: 'transfersInSuspenseAccount',
interestOnSavingsAccountId: 'interestOnSavingsAccount',
incomeFromFeeAccountId: 'incomeFromFeeAccount',
incomeFromPenaltyAccountId: 'incomeFromPenaltyAccount',
incomeFromInterestId: 'incomeFromInterest',
overdraftPortfolioControlId: 'overdraftPortfolioControl',
writeOffAccountId: 'writeOffAccount',
feesReceivableAccountId: 'feeReceivableAccount',
penaltiesReceivableAccountId: 'penaltyReceivableAccount',
interestPayableAccountId: 'interestPayableAccount',
interestReceivableAccountId: 'interestReceivableAccount',
escheatLiabilityId: 'escheatLiabilityAccount'
};
private glAccountNamesById = new Map<string, string>();
ngOnInit(): void {
this.productName = this.data.productName ?? '';
const original = this.data.original ?? {};
const proposed = this.data.proposed ?? {};
this.buildGlAccountLookup(original);
this.rows = Object.keys(proposed)
.filter((key) => !ProductEditDiffDialogComponent.IGNORED_KEYS.has(key))
.map((key) => ({ key, originalValue: this.originalValueFor(key, original) }))
.filter(({ key, originalValue }) => !this.sameValue(originalValue, proposed[key]))
.map(({ key, originalValue }) => ({
label: this.humanizeKey(key),
original: this.displayValue(originalValue, key, original),
proposed: this.displayValue(proposed[key], key, original)
}))
.sort((a, b) => a.label.localeCompare(b.label));
}
/** Pools every GL account the template offers so an account id can be shown as its name. */
private buildGlAccountLookup(template: any): void {
const optionGroups = template?.accountingMappingOptions ?? {};
for (const group of Object.values<any>(optionGroups)) {
if (!Array.isArray(group)) {
continue;
}
for (const account of group) {
if (account?.id === undefined) {
continue;
}
const label = account.name ?? account.glCode ?? null;
if (label) {
this.glAccountNamesById.set(String(account.id), account.glCode ? `${label} (${account.glCode})` : label);
}
}
}
}
/** Reads the current value for a payload field, following an alias or GL mapping when needed. */
private originalValueFor(key: string, original: any): any {
const glMappingKey = ProductEditDiffDialogComponent.GL_FIELD_TO_MAPPING[key];
if (glMappingKey) {
return original?.accountingMappings?.[glMappingKey] ?? null;
}
const alias = ProductEditDiffDialogComponent.FIELD_ALIASES[key];
if (alias) {
return alias.split('.').reduce((acc: any, part: string) => (acc == null ? acc : acc[part]), original);
}
return original?.[key];
}
/**
* Enum/option fields arrive from the edit as a bare id, which reads as a meaningless number in the
* table. The product fetched with template=true carries the matching option list under a predictable
* `<field>Options` key (accountingRule -> accountingRuleOptions), so the id is resolved back to the
* label the maker actually picked. Returns null when there is no such list to resolve against.
*/
private resolveOptionLabel(fieldKey: string, id: any, template: any): string | null {
if (ProductEditDiffDialogComponent.GL_FIELD_TO_MAPPING[fieldKey]) {
return this.glAccountNamesById.get(String(id)) ?? null;
}
const options = template?.[`${fieldKey}Options`];
if (!Array.isArray(options)) {
return null;
}
const match = options.find((option: any) => String(option?.id) === String(id));
if (!match) {
return null;
}
return match.value ?? match.name ?? match.code ?? null;
}
/**
* The stored product returns enum/option fields as objects ({id, value}) while the submitted payload
* carries just the id, so a raw comparison reports every such field as changed. Comparing on the id
* (and coercing numeric strings) keeps the list to fields whose value genuinely moved.
*/
private sameValue(a: any, b: any): boolean {
return JSON.stringify(this.normalize(a)) === JSON.stringify(this.normalize(b));
}
private normalize(value: any): any {
if (value === null || value === undefined || value === '') {
return null;
}
// Dates come back from the product as [yyyy, m, d] but are submitted as "yyyy-MM-dd"; without
// folding both to one form every date field reads as changed on every edit.
const asDate = this.toIsoDate(value);
if (asDate !== null) {
return asDate;
}
if (Array.isArray(value)) {
// An empty array and an absent value mean the same thing here.
return value.length === 0 ? null : value.map((item) => this.normalize(item));
}
if (typeof value === 'object') {
if (value.id !== undefined) {
return this.normalize(value.id);
}
return Object.keys(value).length === 0 ? null : JSON.stringify(value);
}
if (typeof value === 'boolean') {
return value;
}
const asNumber = Number(value);
return Number.isNaN(asNumber) ? value : asNumber;
}
/** Recognises both date shapes the API uses and returns "yyyy-MM-dd", or null if not a date. */
private toIsoDate(value: any): string | null {
if (Array.isArray(value) && value.length === 3 && value.every((part) => typeof part === 'number')) {
const [
year,
month,
day
] = value;
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
return value;
}
return null;
}
private displayValue(value: any, fieldKey: string, template: any): string {
if (value === null || value === undefined || value === '') {
return '—';
}
const asDate = this.toIsoDate(value);
if (asDate !== null) {
return asDate;
}
if (Array.isArray(value)) {
return value.length === 0 ? '—' : `${value.length} item${value.length === 1 ? '' : 's'}`;
}
if (typeof value === 'object') {
if (Object.keys(value).length === 0) {
return '—';
}
// An expanded GL account -- shown the same way as the resolved id on the other side.
if (value.name && value.glCode) {
return `${value.name} (${value.glCode})`;
}
// Already an expanded {id, value} option from the stored product.
return value.value ?? value.name ?? value.code ?? (value.id !== undefined ? String(value.id) : '—');
}
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
// A bare id that names an option -- show the option's label, not the number.
return this.resolveOptionLabel(fieldKey, value, template) ?? String(value);
}
/** camelCase / PascalCase field name to a spaced, capitalised label. */
private humanizeKey(key: string): string {
const spaced = key
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
.replace(/[_-]+/g, ' ')
.trim();
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
}
+2 -5
View File
@@ -1469,9 +1469,7 @@
"General Information": "General Information",
"Recurrence Settings": "Recurrence Settings",
"To Account": "To Account",
"Transfer Settings": "Transfer Settings",
"Additional Settings (Legacy Account Type)": "Additional Settings (Legacy Account Type)",
"Interest Rate Detail": "Interest Rate Detail"
"Transfer Settings": "Transfer Settings"
},
"inputs": {
"# Remittance": "# Remittance",
@@ -3087,8 +3085,7 @@
"to": "to",
"valid": "valid",
"Valid From": "Valid From",
"Valid Till": "Valid Till",
"Field": "Field"
"Valid Till": "Valid Till"
},
"links": {
"Community": "Community",