update saving product code

This commit is contained in:
Nitesh Yadav
2026-04-13 14:02:54 +05:30
parent 79e455c4ed
commit 4679c6f729
37 changed files with 4103 additions and 1889 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"permissions": {
"allow": [
"Bash(npx *)",
"Bash(git add *)",
"Bash(git commit *)",
"Bash(select-object *)",
"Bash(git config *)",
"Bash(set *)"
]
},
"$version": 3
}
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(npx *)"
]
}
}
+2 -2
View File
@@ -87,9 +87,9 @@ export class Accounting {
} else if (accountingRuleId === 2) { } else if (accountingRuleId === 2) {
return { code: 'CASH', id: accountingRuleId, value: 'Cash' }; return { code: 'CASH', id: accountingRuleId, value: 'Cash' };
} else if (accountingRuleId === 3) { } else if (accountingRuleId === 3) {
return { code: 'ACCRUAL_PERIODIC', id: accountingRuleId, value: 'Accural (periodic)' }; return { code: 'ACCRUAL_PERIODIC', id: accountingRuleId, value: 'Accrual (periodic)' };
} else if (accountingRuleId === 4) { } else if (accountingRuleId === 4) {
return { code: 'ACCRUAL_UPFRONT', id: accountingRuleId, value: 'Accural (upfront)' }; return { code: 'ACCRUAL_UPFRONT', id: accountingRuleId, value: 'Accrual (upfront)' };
} }
return { code: 'INVALID', id: accountingRuleId, value: 'Invalid' }; return { code: 'INVALID', id: accountingRuleId, value: 'Invalid' };
} }
@@ -12,14 +12,21 @@
<h2 class="sp-header__title">{{ 'labels.heading.Create Savings Product' | translate }}</h2> <h2 class="sp-header__title">{{ 'labels.heading.Create Savings Product' | translate }}</h2>
</div> </div>
<mat-tab-group class="sp-tabs" animationDuration="200ms" [preserveContent]="true" [(selectedIndex)]="selectedTabIndex"> <mat-tab-group
class="sp-tabs"
animationDuration="200ms"
[preserveContent]="true"
[(selectedIndex)]="selectedTabIndex"
>
<mat-tab> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
<i class="fas fa-info-circle sp-tab-icon"></i> <i class="fas fa-info-circle sp-tab-icon"></i>
{{ 'labels.inputs.DETAILS' | translate }} {{ 'labels.inputs.DETAILS' | translate }}
</ng-template> </ng-template>
<div class="sp-tab-content"> <div class="sp-tab-content">
<mifosx-saving-product-details-step></mifosx-saving-product-details-step> <mifosx-saving-product-details-step
[savingProductsTemplate]="savingProductsTemplate"
></mifosx-saving-product-details-step>
</div> </div>
</mat-tab> </mat-tab>
@@ -72,6 +79,18 @@
</div> </div>
</mat-tab> </mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-mobile-alt sp-tab-icon"></i>
Digital
</ng-template>
<div class="sp-tab-content">
<mifosx-saving-product-limits-step
[savingProductsTemplate]="savingProductsTemplate"
></mifosx-saving-product-limits-step>
</div>
</mat-tab>
<mat-tab> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
<i class="fas fa-book sp-tab-icon"></i> <i class="fas fa-book sp-tab-icon"></i>
@@ -114,19 +133,19 @@
<div class="sp-action-bar__right"> <div class="sp-action-bar__right">
@if (selectedTabIndex > 0) { @if (selectedTabIndex > 0) {
<button mat-raised-button (click)="selectedTabIndex = selectedTabIndex - 1"> <button mat-raised-button (click)="selectedTabIndex = selectedTabIndex - 1">
<i class="fas fa-arrow-left" style="margin-right: 8px;"></i> <i class="fas fa-arrow-left" style="margin-right: 8px"></i>
{{ 'labels.buttons.Previous' | translate }} {{ 'labels.buttons.Previous' | translate }}
</button> </button>
} }
@if (selectedTabIndex < totalTabs - 1) { @if (selectedTabIndex < totalTabs - 1) {
<button mat-raised-button color="primary" (click)="selectedTabIndex = selectedTabIndex + 1"> <button mat-raised-button color="primary" (click)="selectedTabIndex = selectedTabIndex + 1">
{{ 'labels.buttons.Next' | translate }} {{ 'labels.buttons.Next' | translate }}
<i class="fas fa-arrow-right" style="margin-left: 8px;"></i> <i class="fas fa-arrow-right" style="margin-left: 8px"></i>
</button> </button>
} }
@if (savingProductFormValid && selectedTabIndex === totalTabs - 1) { @if (savingProductFormValid && selectedTabIndex === totalTabs - 1) {
<button mat-raised-button color="primary" (click)="submit()"> <button mat-raised-button color="primary" (click)="submit()">
<i class="fas fa-check" style="margin-right: 8px;"></i> <i class="fas fa-check" style="margin-right: 8px"></i>
{{ 'labels.buttons.Submit' | translate }} {{ 'labels.buttons.Submit' | translate }}
</button> </button>
} }
@@ -35,11 +35,11 @@ $primary: #1353a0;
.sp-tabs { .sp-tabs {
background: var(--md-sys-color-surface, #fff); background: var(--md-sys-color-surface, #fff);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
::ng-deep { ::ng-deep {
.mat-mdc-tab-header { .mat-mdc-tab-header {
border-bottom: 1px solid rgba(0, 0, 0, 0.08); border-bottom: 1px solid rgb(0 0 0 / 8%);
} }
.mat-mdc-tab { .mat-mdc-tab {
@@ -77,10 +77,10 @@ $primary: #1353a0;
align-items: center; align-items: center;
margin-top: 20px; margin-top: 20px;
padding: 16px 0; padding: 16px 0;
border-top: 1px solid rgba(0, 0, 0, 0.08); border-top: 1px solid rgb(0 0 0 / 8%);
&__cancel { &__cancel {
color: rgba(0, 0, 0, 0.6); color: rgb(0 0 0 / 60%);
} }
&__right { &__right {
@@ -1,4 +1,4 @@
/** /**
* Copyright since 2025 Mifos Initiative * Copyright since 2025 Mifos Initiative
* *
* This Source Code Form is subject to the terms of the Mozilla Public * This Source Code Form is subject to the terms of the Mozilla Public
@@ -16,6 +16,7 @@ import { SavingProductCurrencyStepComponent } from '../saving-product-stepper/sa
import { SavingProductTermsStepComponent } from '../saving-product-stepper/saving-product-terms-step/saving-product-terms-step.component'; import { SavingProductTermsStepComponent } from '../saving-product-stepper/saving-product-terms-step/saving-product-terms-step.component';
import { SavingProductSettingsStepComponent } from '../saving-product-stepper/saving-product-settings-step/saving-product-settings-step.component'; import { SavingProductSettingsStepComponent } from '../saving-product-stepper/saving-product-settings-step/saving-product-settings-step.component';
import { SavingProductChargesStepComponent } from '../saving-product-stepper/saving-product-charges-step/saving-product-charges-step.component'; import { SavingProductChargesStepComponent } from '../saving-product-stepper/saving-product-charges-step/saving-product-charges-step.component';
import { SavingProductLimitsStepComponent } from '../saving-product-stepper/saving-product-limits-step/saving-product-limits-step.component';
import { SavingProductAccountingStepComponent } from '../saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component'; import { SavingProductAccountingStepComponent } from '../saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component';
/** Custom Services */ /** Custom Services */
@@ -38,6 +39,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
SavingProductTermsStepComponent, SavingProductTermsStepComponent,
SavingProductSettingsStepComponent, SavingProductSettingsStepComponent,
SavingProductChargesStepComponent, SavingProductChargesStepComponent,
SavingProductLimitsStepComponent,
SavingProductAccountingStepComponent, SavingProductAccountingStepComponent,
SavingProductPreviewStepComponent SavingProductPreviewStepComponent
] ]
@@ -58,6 +60,8 @@ export class CreateSavingProductComponent {
savingProductSettingsStep: SavingProductSettingsStepComponent; savingProductSettingsStep: SavingProductSettingsStepComponent;
@ViewChild(SavingProductChargesStepComponent, { static: true }) @ViewChild(SavingProductChargesStepComponent, { static: true })
savingProductChargesStep: SavingProductChargesStepComponent; savingProductChargesStep: SavingProductChargesStepComponent;
@ViewChild(SavingProductLimitsStepComponent, { static: true })
savingProductLimitsStep: SavingProductLimitsStepComponent;
@ViewChild(SavingProductAccountingStepComponent, { static: true }) @ViewChild(SavingProductAccountingStepComponent, { static: true })
savingProductAccountingStep: SavingProductAccountingStepComponent; savingProductAccountingStep: SavingProductAccountingStepComponent;
@@ -100,7 +104,7 @@ export class CreateSavingProductComponent {
} }
get totalTabs(): number { get totalTabs(): number {
return this.savingProductFormValid ? 7 : 6; return this.savingProductFormValid ? 8 : 7;
} }
get savingProductFormValid() { get savingProductFormValid() {
@@ -120,18 +124,44 @@ export class CreateSavingProductComponent {
...this.savingProductTermsStep.savingProductTerms, ...this.savingProductTermsStep.savingProductTerms,
...this.savingProductSettingsStep.savingProductSettings, ...this.savingProductSettingsStep.savingProductSettings,
...this.savingProductChargesStep.savingProductCharges, ...this.savingProductChargesStep.savingProductCharges,
...this.savingProductLimitsStep.savingProductLimits,
...this.savingProductAccountingStep.savingProductAccounting ...this.savingProductAccountingStep.savingProductAccounting
}; };
} }
submit() { private prepareSavingProductPayload() {
// TODO: Update once language and date settings are setup
const savingProduct = { const savingProduct = {
...this.savingProduct, ...this.savingProduct,
charges: this.savingProduct.charges.map((charge: any) => ({ id: charge.id })), charges: this.savingProduct.charges.map((charge: any) => ({ id: charge.id })),
locale: this.settingsService.language.code // locale required for nominalAnnualInterestRate locale: this.settingsService.language.code,
dateFormat: 'dd MMMM yyyy'
}; };
delete savingProduct.advancedAccountingRules; delete savingProduct.advancedAccountingRules;
// Convert lockinPeriodFrequencyType from string to integer
// Backend expects: 0=DAYS, 1=WEEKS, 2=MONTHS, 3=YEARS
if (savingProduct.lockinPeriodFrequencyType) {
const frequencyTypeMapping: { [key: string]: number } = {
DAYS: 0,
WEEKS: 1,
MONTHS: 2,
YEARS: 3
};
savingProduct.lockinPeriodFrequencyType = frequencyTypeMapping[savingProduct.lockinPeriodFrequencyType];
}
Object.keys(savingProduct).forEach((key) => {
if (savingProduct[key] === '' || savingProduct[key] === null || savingProduct[key] === undefined) {
delete savingProduct[key];
}
});
return savingProduct;
}
submit() {
const savingProduct = this.prepareSavingProductPayload();
console.log('Saving product submit payload:', savingProduct);
this.productsService.createSavingProduct(savingProduct).subscribe((response: any) => { this.productsService.createSavingProduct(savingProduct).subscribe((response: any) => {
this.router.navigate( this.router.navigate(
[ [
@@ -12,7 +12,12 @@
<h2 class="sp-header__title">{{ 'labels.heading.Edit Savings Product' | translate }}</h2> <h2 class="sp-header__title">{{ 'labels.heading.Edit Savings Product' | translate }}</h2>
</div> </div>
<mat-tab-group class="sp-tabs" animationDuration="200ms" [preserveContent]="true" [(selectedIndex)]="selectedTabIndex"> <mat-tab-group
class="sp-tabs"
animationDuration="200ms"
[preserveContent]="true"
[(selectedIndex)]="selectedTabIndex"
>
<mat-tab> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
<i class="fas fa-info-circle sp-tab-icon"></i> <i class="fas fa-info-circle sp-tab-icon"></i>
@@ -74,6 +79,18 @@
</div> </div>
</mat-tab> </mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-mobile-alt sp-tab-icon"></i>
Digital
</ng-template>
<div class="sp-tab-content">
<mifosx-saving-product-limits-step
[savingProductsTemplate]="savingProductAndTemplate"
></mifosx-saving-product-limits-step>
</div>
</mat-tab>
<mat-tab> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
<i class="fas fa-book sp-tab-icon"></i> <i class="fas fa-book sp-tab-icon"></i>
@@ -116,19 +133,19 @@
<div class="sp-action-bar__right"> <div class="sp-action-bar__right">
@if (selectedTabIndex > 0) { @if (selectedTabIndex > 0) {
<button mat-raised-button (click)="selectedTabIndex = selectedTabIndex - 1"> <button mat-raised-button (click)="selectedTabIndex = selectedTabIndex - 1">
<i class="fas fa-arrow-left" style="margin-right: 8px;"></i> <i class="fas fa-arrow-left" style="margin-right: 8px"></i>
{{ 'labels.buttons.Previous' | translate }} {{ 'labels.buttons.Previous' | translate }}
</button> </button>
} }
@if (selectedTabIndex < totalTabs - 1) { @if (selectedTabIndex < totalTabs - 1) {
<button mat-raised-button color="primary" (click)="selectedTabIndex = selectedTabIndex + 1"> <button mat-raised-button color="primary" (click)="selectedTabIndex = selectedTabIndex + 1">
{{ 'labels.buttons.Next' | translate }} {{ 'labels.buttons.Next' | translate }}
<i class="fas fa-arrow-right" style="margin-left: 8px;"></i> <i class="fas fa-arrow-right" style="margin-left: 8px"></i>
</button> </button>
} }
@if (savingProductFormValidAndNotPristine && selectedTabIndex === totalTabs - 1) { @if (savingProductFormValidAndNotPristine && selectedTabIndex === totalTabs - 1) {
<button mat-raised-button color="primary" (click)="submit()"> <button mat-raised-button color="primary" (click)="submit()">
<i class="fas fa-check" style="margin-right: 8px;"></i> <i class="fas fa-check" style="margin-right: 8px"></i>
{{ 'labels.buttons.Submit' | translate }} {{ 'labels.buttons.Submit' | translate }}
</button> </button>
} }
@@ -35,11 +35,11 @@ $primary: #1353a0;
.sp-tabs { .sp-tabs {
background: var(--md-sys-color-surface, #fff); background: var(--md-sys-color-surface, #fff);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
::ng-deep { ::ng-deep {
.mat-mdc-tab-header { .mat-mdc-tab-header {
border-bottom: 1px solid rgba(0, 0, 0, 0.08); border-bottom: 1px solid rgb(0 0 0 / 8%);
} }
.mat-mdc-tab { .mat-mdc-tab {
@@ -77,10 +77,10 @@ $primary: #1353a0;
align-items: center; align-items: center;
margin-top: 20px; margin-top: 20px;
padding: 16px 0; padding: 16px 0;
border-top: 1px solid rgba(0, 0, 0, 0.08); border-top: 1px solid rgb(0 0 0 / 8%);
&__cancel { &__cancel {
color: rgba(0, 0, 0, 0.6); color: rgb(0 0 0 / 60%);
} }
&__right { &__right {
@@ -1,4 +1,4 @@
/** /**
* Copyright since 2025 Mifos Initiative * Copyright since 2025 Mifos Initiative
* *
* This Source Code Form is subject to the terms of the Mozilla Public * This Source Code Form is subject to the terms of the Mozilla Public
@@ -16,6 +16,7 @@ import { SavingProductCurrencyStepComponent } from '../saving-product-stepper/sa
import { SavingProductTermsStepComponent } from '../saving-product-stepper/saving-product-terms-step/saving-product-terms-step.component'; import { SavingProductTermsStepComponent } from '../saving-product-stepper/saving-product-terms-step/saving-product-terms-step.component';
import { SavingProductSettingsStepComponent } from '../saving-product-stepper/saving-product-settings-step/saving-product-settings-step.component'; import { SavingProductSettingsStepComponent } from '../saving-product-stepper/saving-product-settings-step/saving-product-settings-step.component';
import { SavingProductChargesStepComponent } from '../saving-product-stepper/saving-product-charges-step/saving-product-charges-step.component'; import { SavingProductChargesStepComponent } from '../saving-product-stepper/saving-product-charges-step/saving-product-charges-step.component';
import { SavingProductLimitsStepComponent } from '../saving-product-stepper/saving-product-limits-step/saving-product-limits-step.component';
import { SavingProductAccountingStepComponent } from '../saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component'; import { SavingProductAccountingStepComponent } from '../saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component';
/** Custom Services */ /** Custom Services */
@@ -38,6 +39,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
SavingProductTermsStepComponent, SavingProductTermsStepComponent,
SavingProductSettingsStepComponent, SavingProductSettingsStepComponent,
SavingProductChargesStepComponent, SavingProductChargesStepComponent,
SavingProductLimitsStepComponent,
SavingProductAccountingStepComponent, SavingProductAccountingStepComponent,
SavingProductPreviewStepComponent SavingProductPreviewStepComponent
] ]
@@ -58,6 +60,8 @@ export class EditSavingProductComponent {
savingProductSettingsStep: SavingProductSettingsStepComponent; savingProductSettingsStep: SavingProductSettingsStepComponent;
@ViewChild(SavingProductChargesStepComponent, { static: true }) @ViewChild(SavingProductChargesStepComponent, { static: true })
savingProductChargesStep: SavingProductChargesStepComponent; savingProductChargesStep: SavingProductChargesStepComponent;
@ViewChild(SavingProductLimitsStepComponent, { static: true })
savingProductLimitsStep: SavingProductLimitsStepComponent;
@ViewChild(SavingProductAccountingStepComponent, { static: true }) @ViewChild(SavingProductAccountingStepComponent, { static: true })
savingProductAccountingStep: SavingProductAccountingStepComponent; savingProductAccountingStep: SavingProductAccountingStepComponent;
@@ -100,7 +104,7 @@ export class EditSavingProductComponent {
} }
get totalTabs(): number { get totalTabs(): number {
return this.savingProductFormValidAndNotPristine ? 7 : 6; return this.savingProductFormValidAndNotPristine ? 8 : 7;
} }
get savingProductFormValidAndNotPristine() { get savingProductFormValidAndNotPristine() {
@@ -115,6 +119,7 @@ export class EditSavingProductComponent {
!this.savingProductTermsForm.pristine || !this.savingProductTermsForm.pristine ||
!this.savingProductSettingsForm.pristine || !this.savingProductSettingsForm.pristine ||
!this.savingProductChargesStep.pristine || !this.savingProductChargesStep.pristine ||
!this.savingProductLimitsStep.pristine ||
!this.savingProductAccountingForm.pristine) !this.savingProductAccountingForm.pristine)
); );
} }
@@ -126,18 +131,44 @@ export class EditSavingProductComponent {
...this.savingProductTermsStep.savingProductTerms, ...this.savingProductTermsStep.savingProductTerms,
...this.savingProductSettingsStep.savingProductSettings, ...this.savingProductSettingsStep.savingProductSettings,
...this.savingProductChargesStep.savingProductCharges, ...this.savingProductChargesStep.savingProductCharges,
...this.savingProductLimitsStep.savingProductLimits,
...this.savingProductAccountingStep.savingProductAccounting ...this.savingProductAccountingStep.savingProductAccounting
}; };
} }
submit() { private prepareSavingProductPayload() {
// TODO: Update once language and date settings are setup
const savingProduct = { const savingProduct = {
...this.savingProduct, ...this.savingProduct,
charges: this.savingProduct.charges.map((charge: any) => ({ id: charge.id })), charges: this.savingProduct.charges.map((charge: any) => ({ id: charge.id })),
locale: this.settingsService.language.code // locale required for nominalAnnualInterestRate locale: this.settingsService.language.code,
dateFormat: 'dd MMMM yyyy'
}; };
delete savingProduct.advancedAccountingRules; delete savingProduct.advancedAccountingRules;
// Convert lockinPeriodFrequencyType from string to integer
// Backend expects: 0=DAYS, 1=WEEKS, 2=MONTHS, 3=YEARS
if (savingProduct.lockinPeriodFrequencyType) {
const frequencyTypeMapping: { [key: string]: number } = {
DAYS: 0,
WEEKS: 1,
MONTHS: 2,
YEARS: 3
};
savingProduct.lockinPeriodFrequencyType = frequencyTypeMapping[savingProduct.lockinPeriodFrequencyType];
}
Object.keys(savingProduct).forEach((key) => {
if (savingProduct[key] === '' || savingProduct[key] === null || savingProduct[key] === undefined) {
delete savingProduct[key];
}
});
return savingProduct;
}
submit() {
const savingProduct = this.prepareSavingProductPayload();
console.log('Saving product update payload:', savingProduct);
this.productsService this.productsService
.updateSavingProduct(this.savingProductAndTemplate.id, savingProduct) .updateSavingProduct(this.savingProductAndTemplate.id, savingProduct)
.subscribe((response: any) => { .subscribe((response: any) => {
@@ -1,4 +1,4 @@
<!-- <!--
Copyright since 2025 Mifos Initiative Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public This Source Code Form is subject to the terms of the Mozilla Public
@@ -7,13 +7,19 @@
--> -->
<form [formGroup]="savingProductAccountingForm"> <form [formGroup]="savingProductAccountingForm">
<!-- Accounting Rule -->
<div class="spa-section"> <div class="spa-section">
<div class="spa-section__header"> <div class="spa-section__header">
<span class="spa-section__icon">
<i class="fas fa-book"></i> <i class="fas fa-book"></i>
<h3>{{ 'labels.inputs.Accounting Rule' | translate }}</h3> </span>
<h3>Accounting</h3>
</div>
<div class="spa-section__body">
<div class="spa-group">
<div class="spa-group__header">
<h4>Accounting Rule</h4>
</div> </div>
<div class="spa-body">
<mat-radio-group class="spa-radio-group" formControlName="accountingRule"> <mat-radio-group class="spa-radio-group" formControlName="accountingRule">
@for (accountingRule of accountingRuleData; track accountingRule; let i = $index) { @for (accountingRule of accountingRuleData; track accountingRule; let i = $index) {
<mat-radio-button [value]="i + 1"> <mat-radio-button [value]="i + 1">
@@ -22,341 +28,105 @@
} }
</mat-radio-group> </mat-radio-group>
</div> </div>
@if (!isCashOrAccrualAccounting()) {
<div class="spa-note">
Select a cash or accrual accounting rule to configure GL mappings. The default "None" rule does not require
any accounting mappings.
</div> </div>
}
@if (isCashOrAccrualAccounting()) { @if (isCashOrAccrualAccounting()) {
<!-- Assets --> @for (section of accountingSectionConfigs; track section.key) {
<div class="spa-section"> <div class="spa-group">
<div class="spa-section__header"> <div class="spa-group__header">
<i class="fas fa-university"></i> <h4>{{ section.title }}</h4>
<h3>{{ 'labels.heading.Assets' | translate }}</h3> <button type="button" class="spa-add-btn" (click)="addOtherGlMapping(section.otherMappingsControlName)">
+ Add Other
</button>
</div>
<div class="spa-list">
@for (row of section.rows; track row.key) {
@if (
(!row.accrualOnly || isAccrualAccounting()) && (!row.dormancyOnly || isDormancyTrackingEnabled())
) {
<div class="spa-row">
<div class="spa-row__meta">
<span
class="spa-pill"
[class.spa-pill--green]="section.pillClass === 'green'"
[class.spa-pill--amber]="section.pillClass === 'amber'"
[class.spa-pill--blue]="section.pillClass === 'blue'"
>
{{ section.pillLabel }}
</span>
<span class="spa-row__label">{{ row.label }}</span>
</div> </div>
<div class="spa-grid">
<mifosx-gl-account-selector <mifosx-gl-account-selector
class="spa-field" class="spa-row__input"
[inputFormControl]="savingProductAccountingForm.controls.savingsReferenceAccountId" [inputFormControl]="savingProductAccountingForm.get(row.controlName)"
[glAccountList]="assetAccountData" [glAccountList]="row.options"
[required]="true" [required]="row.required"
[inputLabel]="'Saving reference'" [inputLabel]="row.inputLabel"
></mifosx-gl-account-selector>
<mifosx-gl-account-selector
class="spa-field"
[inputFormControl]="savingProductAccountingForm.controls.overdraftPortfolioControlId"
[glAccountList]="assetAccountData"
[required]="true"
[inputLabel]="'Overdraft portfolio'"
></mifosx-gl-account-selector>
@if (isAccrualAccounting()) {
<mifosx-gl-account-selector
class="spa-field"
[inputFormControl]="savingProductAccountingForm.controls.feesReceivableAccountId"
[glAccountList]="assetAccountData"
[required]="true"
[inputLabel]="'Fees Receivable'"
></mifosx-gl-account-selector>
<mifosx-gl-account-selector
class="spa-field"
[inputFormControl]="savingProductAccountingForm.controls.penaltiesReceivableAccountId"
[glAccountList]="assetAccountData"
[required]="true"
[inputLabel]="'Penalties Receivable'"
></mifosx-gl-account-selector> ></mifosx-gl-account-selector>
</div>
} }
@if (isAccrualAccounting() && allowOverdraft.value) {
<mifosx-gl-account-selector
class="spa-field"
[inputFormControl]="savingProductAccountingForm.controls.interestReceivableAccountId"
[glAccountList]="assetAccountData"
[required]="false"
[inputLabel]="'Interest Receivable'"
></mifosx-gl-account-selector>
} }
</div> </div>
<div class="spa-list" [formArrayName]="section.otherMappingsControlName">
@for (
otherRow of getOtherGlMappingsArray(section.otherMappingsControlName).controls;
track $index;
let i = $index
) {
<div class="spa-other-row-wrap">
<div class="spa-row spa-row--other" [formGroupName]="i">
<div class="spa-row__meta spa-row__meta--other">
<span
class="spa-pill"
[class.spa-pill--green]="section.pillClass === 'green'"
[class.spa-pill--amber]="section.pillClass === 'amber'"
[class.spa-pill--blue]="section.pillClass === 'blue'"
>
{{ section.pillLabel }}
</span>
<input
class="spa-row__name spa-row__name--other-account"
formControlName="accountName"
placeholder="Enter account name..."
type="text"
/>
</div> </div>
<!-- Liabilities --> <div class="spa-row__actions spa-row__actions--other">
<div class="spa-section"> <mat-form-field class="spa-row__other-field" appearance="outline" subscriptSizing="dynamic">
<div class="spa-section__header"> <mat-label>GL Code</mat-label>
<i class="fas fa-file-invoice-dollar"></i> <mat-select
<h3>{{ 'labels.heading.Liabilities' | translate }}</h3> [formControl]="getOtherGlMappingControl(section.otherMappingsControlName, i, 'glAccountId')"
</div> >
<div class="spa-grid"> @for (glAccount of section.otherAccountOptions; track glAccount.id) {
<mifosx-gl-account-selector <mat-option [value]="glAccount.id">
class="spa-field" {{ '(' + glAccount.glCode + ') ' + glAccount.name }}
[inputFormControl]="savingProductAccountingForm.controls.savingsControlAccountId" </mat-option>
[glAccountList]="liabilityAccountData"
[required]="true"
[inputLabel]="'Saving control'"
></mifosx-gl-account-selector>
<mifosx-gl-account-selector
class="spa-field"
[inputFormControl]="savingProductAccountingForm.controls.transfersInSuspenseAccountId"
[glAccountList]="liabilityAccountData"
[required]="true"
[inputLabel]="'Savings transfers in suspense'"
></mifosx-gl-account-selector>
@if (isAccrualAccounting()) {
<mifosx-gl-account-selector
class="spa-field"
[inputFormControl]="savingProductAccountingForm.controls.interestPayableAccountId"
[glAccountList]="liabilityAccountData"
[required]="true"
[inputLabel]="'Interest Payable'"
></mifosx-gl-account-selector>
}
@if (isDormancyTrackingActive.value) {
<mifosx-gl-account-selector
class="spa-field"
[inputFormControl]="savingProductAccountingForm.controls.escheatLiabilityId"
[glAccountList]="liabilityAccountData"
[required]="true"
[inputLabel]="'Escheat liability'"
></mifosx-gl-account-selector>
} }
</mat-select>
</mat-form-field>
</div> </div>
</div> </div>
<!-- Expenses -->
<div class="spa-section">
<div class="spa-section__header">
<i class="fas fa-money-bill-wave"></i>
<h3>{{ 'labels.heading.Expenses' | translate }}</h3>
</div>
<div class="spa-grid">
<mifosx-gl-account-selector
class="spa-field"
[inputFormControl]="savingProductAccountingForm.controls.interestOnSavingsAccountId"
[glAccountList]="expenseAccountData"
[required]="true"
[inputLabel]="'Interest on savings'"
></mifosx-gl-account-selector>
<mifosx-gl-account-selector
class="spa-field"
[inputFormControl]="savingProductAccountingForm.controls.writeOffAccountId"
[glAccountList]="expenseAccountData"
[required]="true"
[inputLabel]="'Write-off'"
></mifosx-gl-account-selector>
</div>
</div>
<!-- Income -->
<div class="spa-section">
<div class="spa-section__header">
<i class="fas fa-chart-line"></i>
<h3>{{ 'labels.heading.Income' | translate }}</h3>
</div>
<div class="spa-grid">
<mifosx-gl-account-selector
class="spa-field"
[inputFormControl]="savingProductAccountingForm.controls.incomeFromFeeAccountId"
[glAccountList]="incomeAccountData"
[required]="true"
[inputLabel]="'Income from fees'"
></mifosx-gl-account-selector>
<mifosx-gl-account-selector
class="spa-field"
[inputFormControl]="savingProductAccountingForm.controls.incomeFromPenaltyAccountId"
[glAccountList]="incomeAccountData"
[required]="true"
[inputLabel]="'Income from penalties'"
></mifosx-gl-account-selector>
<mifosx-gl-account-selector
class="spa-field"
[inputFormControl]="savingProductAccountingForm.controls.incomeFromInterestId"
[glAccountList]="incomeAccountData"
[required]="true"
[inputLabel]="'Overdraft Interest Income'"
></mifosx-gl-account-selector>
</div>
</div>
<!-- Advanced Accounting Rules -->
<div class="spa-section">
<div class="spa-section__header">
<i class="fas fa-cogs"></i>
<h3>{{ 'labels.heading.Advanced Accounting Rules' | translate }}</h3>
</div>
<div class="spa-body">
<div class="spa-checkbox-row">
<mat-checkbox formControlName="advancedAccountingRules">
{{ 'labels.heading.Advanced Accounting Rules' | translate }}
</mat-checkbox>
</div>
@if (savingProductAccountingForm.value.advancedAccountingRules) {
<!-- Payment Fund Sources -->
<div class="spa-subsection">
<div class="spa-subsection__header">
<h4>{{ 'labels.heading.Configure Fund Sources for Payment Channels' | translate }}</h4>
<button <button
type="button" type="button"
mat-flat-button class="spa-remove-btn spa-remove-btn--side"
color="primary" (click)="removeOtherGlMapping(section.otherMappingsControlName, i)"
(click)="add('PaymentFundSource', paymentChannelToFundSourceMappings)"
class="spa-add-btn"
> >
<fa-icon icon="plus" class="m-r-10"></fa-icon> <i class="fas fa-times"></i>
{{ 'labels.buttons.Add' | translate }}
</button> </button>
</div> </div>
@if (paymentChannelToFundSourceMappings.value.length !== 0) {
<div class="spa-table-wrap">
<table mat-table [dataSource]="paymentChannelToFundSourceMappings.value" class="spa-table">
<ng-container matColumnDef="paymentTypeId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Payment Type' | translate }}</th>
<td mat-cell *matCellDef="let paymentFundSource">
{{ paymentFundSource.paymentTypeId | find: paymentTypeData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="fundSourceAccountId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fund Source' | translate }}</th>
<td mat-cell *matCellDef="let paymentFundSource">
{{ paymentFundSource.fundSourceAccountId | find: combinedAccountData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let paymentFundSource; let i = index">
<button
mat-icon-button
color="primary"
(click)="edit('PaymentFundSource', paymentChannelToFundSourceMappings, i)"
>
<fa-icon icon="edit"></fa-icon>
</button>
<button mat-icon-button color="warn" (click)="delete(paymentChannelToFundSourceMappings, i)">
<fa-icon icon="trash"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="paymentFundSourceDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: paymentFundSourceDisplayedColumns"></tr>
</table>
</div>
} }
</div> </div>
<!-- Fees to Income -->
@if (existCharges()) {
<div class="spa-subsection">
<div class="spa-subsection__header">
<h4>{{ 'labels.heading.Map Fees to Specific Income Accounts' | translate }}</h4>
<button
type="button"
mat-flat-button
color="primary"
(click)="add('FeesIncome', feeToIncomeAccountMappings)"
class="spa-add-btn"
>
<fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Add' | translate }}
</button>
</div>
@if (feeToIncomeAccountMappings.value.length !== 0) {
<div class="spa-table-wrap">
<table mat-table [dataSource]="feeToIncomeAccountMappings.value" class="spa-table">
<ng-container matColumnDef="chargeId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fees' | translate }}</th>
<td mat-cell *matCellDef="let feesIncome">
{{ feesIncome.chargeId | find: chargeData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="incomeAccountId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Income Account' | translate }}</th>
<td mat-cell *matCellDef="let feesIncome">
{{ feesIncome.incomeAccountId | find: incomeAccountData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let feesIncome; let i = index">
<button
mat-icon-button
color="primary"
(click)="edit('FeesIncome', feeToIncomeAccountMappings, i)"
>
<fa-icon icon="edit"></fa-icon>
</button>
<button mat-icon-button color="warn" (click)="delete(feeToIncomeAccountMappings, i)">
<fa-icon icon="trash"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="feesPenaltyIncomeDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: feesPenaltyIncomeDisplayedColumns"></tr>
</table>
</div>
}
</div>
<!-- Penalties to Income -->
<div class="spa-subsection">
<div class="spa-subsection__header">
<h4>{{ 'labels.heading.Map Penalties to Specific Income Accounts' | translate }}</h4>
<button
type="button"
mat-flat-button
color="primary"
(click)="add('PenaltyIncome', penaltyToIncomeAccountMappings)"
class="spa-add-btn"
>
<fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Add' | translate }}
</button>
</div>
@if (penaltyToIncomeAccountMappings.value.length !== 0) {
<div class="spa-table-wrap">
<table mat-table [dataSource]="penaltyToIncomeAccountMappings.value" class="spa-table">
<ng-container matColumnDef="chargeId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Penalty' | translate }}</th>
<td mat-cell *matCellDef="let penaltyIncome">
{{ penaltyIncome.chargeId | find: penaltyData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="incomeAccountId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Income Account' | translate }}</th>
<td mat-cell *matCellDef="let penaltyIncome">
{{ penaltyIncome.incomeAccountId | find: incomeAccountData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let penaltyIncome; let i = index">
<button
mat-icon-button
color="primary"
(click)="edit('PenaltyIncome', penaltyToIncomeAccountMappings, i)"
>
<fa-icon icon="edit"></fa-icon>
</button>
<button mat-icon-button color="warn" (click)="delete(penaltyToIncomeAccountMappings, i)">
<fa-icon icon="trash"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="feesPenaltyIncomeDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: feesPenaltyIncomeDisplayedColumns"></tr>
</table>
</div>
}
</div> </div>
} }
} }
</div> </div>
</div> </div>
}
</form> </form>
@@ -6,26 +6,22 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
$primary: #1353a0;
$accent: #0ea5e9;
$border: #e2e8f0; $border: #e2e8f0;
$gray-50: #f8f9fa; $gray-50: #f8f9fa;
$gray-200: #e8eaed; $gray-200: #e8eaed;
$gray-500: #9aa0a6; $gray-500: #6b7280;
$gray-700: #5f6368; $gray-700: #334155;
$gray-900: #202124; $gray-900: #202124;
$primary: #1353a0;
.spa-section { .spa-section {
border: 1px solid $border; border: 1px solid $border;
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow: hidden;
background: #fff; background: #fff;
}
& + & { .spa-section__header {
margin-top: 16px;
}
&__header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
@@ -33,97 +29,286 @@ $gray-900: #202124;
background: $gray-50; background: $gray-50;
border-bottom: 1px solid $gray-200; border-bottom: 1px solid $gray-200;
i {
font-size: 14px;
color: $accent;
}
h3 { h3 {
margin: 0; margin: 0;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: $gray-900; color: $gray-900;
} }
}
} }
.spa-body { .spa-section__icon {
padding: 16px 24px; display: inline-flex;
}
.spa-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px 16px;
padding: 16px 24px;
@media (max-width: 600px) {
grid-template-columns: 1fr;
}
}
.spa-field {
width: 100%;
&--full {
grid-column: 1 / -1;
}
}
.spa-radio-group {
display: flex;
gap: 24px;
flex-wrap: wrap;
@media (max-width: 600px) {
flex-direction: column;
gap: 12px;
}
}
.spa-checkbox-row {
display: flex;
align-items: center; align-items: center;
padding: 4px 0; justify-content: center;
margin-bottom: 12px; width: 24px;
height: 24px;
border-radius: 6px;
background: #eff6ff;
color: #2563eb;
font-size: 13px;
} }
.spa-subsection { .spa-section__body {
margin-top: 16px; padding: 16px 24px 20px;
padding-top: 16px; }
border-top: 1px solid $gray-200;
.spa-group {
& + & {
margin-top: 20px;
}
&__header { &__header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 16px;
margin-bottom: 12px; margin: 0 0 12px;
}
h4 { h4 {
margin: 0; margin: 0 0 12px;
font-size: 13px; font-size: 12px;
font-weight: 600; font-weight: 700;
color: $gray-700; letter-spacing: 0.08em;
} text-transform: uppercase;
color: #94a3b8;
} }
} }
.spa-add-btn { .spa-add-btn {
height: 32px !important; border: 0;
border-radius: 8px !important; background: transparent;
font-size: 12px !important; color: #2563eb;
font-weight: 600 !important; font-size: 12px;
box-shadow: 0 1px 3px rgba($primary, 0.3) !important; font-weight: 700;
white-space: nowrap; padding: 0;
cursor: pointer;
} }
.spa-table-wrap { .spa-list {
border: 1px solid $border; display: flex;
flex-direction: column;
gap: 10px;
}
.spa-list + .spa-list {
margin-top: 12px;
}
.spa-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px; border-radius: 8px;
overflow: hidden; background: #fff;
@media (width <= 900px) {
flex-direction: column;
align-items: stretch;
}
} }
.spa-table { .spa-row:has(mifosx-gl-account-selector) {
align-items: stretch;
}
.spa-row--other {
border-style: dashed;
border-color: #cbd5e1;
background: #f8fafc;
display: grid;
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
align-items: center;
padding: 12px 16px;
@media (width <= 1100px) {
grid-template-columns: 1fr;
}
}
.spa-other-row-wrap {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
@media (width <= 1100px) {
grid-template-columns: 1fr;
gap: 8px;
}
}
.spa-row__meta {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex: 1 1 auto;
}
.spa-row__meta--other {
min-width: 0;
flex: 1 1 auto;
align-self: stretch;
}
.spa-row__label {
font-size: 14px;
font-weight: 600;
color: $gray-700;
}
.spa-row__input {
flex: 0 0 22rem;
max-width: 22rem;
min-width: 18rem;
@media (width <= 900px) {
width: 100%;
max-width: 100%;
min-width: 0;
}
}
.spa-row__input--other {
flex: 1 1 auto;
max-width: 100%;
min-width: 0;
}
.spa-row__input :is(mifosx-gl-account-selector) {
width: 100%;
display: block;
}
.spa-row__input :is(mifosx-gl-account-selector) :is(.layout-column) {
width: 100%;
margin-right: 0 !important;
}
.spa-row__input :is(mifosx-gl-account-selector) :is(mat-form-field) {
width: 100%; width: 100%;
} }
.spa-row__actions {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.spa-row__actions--other {
justify-content: flex-end;
align-self: stretch;
width: 100%;
@media (width <= 1100px) {
justify-content: flex-start;
}
}
.spa-row__name {
width: 100%;
min-width: 0;
max-width: 24rem;
height: 40px;
border: 1px solid #cbd5e1;
border-radius: 6px;
padding: 0 12px;
font-size: 14px;
background: #fff;
@media (width <= 900px) {
max-width: 100%;
}
}
.spa-row__name--code {
max-width: 20rem;
}
.spa-row__name--other-account {
max-width: 100%;
flex: 1 1 auto;
height: 48px;
}
.spa-row__other-field {
width: 100%;
min-width: 0;
}
.spa-row__other-field :is(mat-form-field) {
width: 100%;
}
.spa-row__other-field :is(.mat-mdc-form-field-required-marker) {
display: none;
}
.spa-remove-btn {
border: 0;
background: transparent;
color: #ef4444;
font-size: 18px;
line-height: 1;
cursor: pointer;
padding: 0 4px;
}
.spa-remove-btn--side {
place-self: center end;
margin-right: 4px;
@media (width <= 1100px) {
justify-self: start;
margin-right: 0;
}
}
.spa-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
&--green {
background: #dcfce7;
color: #16a34a;
}
&--amber {
background: #fef3c7;
color: #d97706;
}
&--blue {
background: #dbeafe;
color: #2563eb;
}
}
datalist {
display: none;
}
@media (width <= 900px) {
.spa-row__meta {
margin-bottom: 4px;
}
.spa-row__input {
flex: 1 1 auto;
max-width: 100%;
}
}
@@ -1,4 +1,4 @@
/** /**
* Copyright since 2025 Mifos Initiative * Copyright since 2025 Mifos Initiative
* *
* This Source Code Form is subject to the terms of the Mozilla Public * This Source Code Form is subject to the terms of the Mozilla Public
@@ -6,489 +6,495 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import { Component, OnInit, Input, inject } from '@angular/core'; import { Component, Input, OnInit, inject } from '@angular/core';
import { import {
UntypedFormGroup, FormsModule,
UntypedFormBuilder, ReactiveFormsModule,
UntypedFormArray, UntypedFormArray,
Validators, UntypedFormBuilder,
UntypedFormControl, UntypedFormControl,
ReactiveFormsModule UntypedFormGroup,
Validators
} from '@angular/forms'; } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog'; import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { GlAccountSelectorComponent } from 'app/shared/accounting/gl-account-selector/gl-account-selector.component';
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component'; import { Accounting } from 'app/core/utils/accounting';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; import { GLAccount } from 'app/shared/models/general.model';
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
import { SelectBase } from 'app/shared/form-dialog/formfield/model/select-base';
import { TranslateService } from '@ngx-translate/core';
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
import { GlAccountSelectorComponent } from '../../../../shared/accounting/gl-account-selector/gl-account-selector.component';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatButton, MatIconButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import {
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
} from '@angular/material/table';
import { FindPipe } from '../../../../pipes/find.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
type AccountingSectionKey = 'assets' | 'liabilities' | 'expenses' | 'income';
type AccountingRowConfig = {
key: string;
label: string;
inputLabel: string;
controlName: string;
options: GLAccount[];
required?: boolean;
accrualOnly?: boolean;
dormancyOnly?: boolean;
};
type AccountingSectionConfig = {
key: AccountingSectionKey;
title: string;
pillLabel: string;
pillClass: 'green' | 'amber' | 'blue';
rows: AccountingRowConfig[];
otherMappingsControlName: string;
otherAccountOptions: GLAccount[];
};
type OtherGlMappingValue = {
accountName: string;
glAccountId: number | null;
};
@Component({ @Component({
selector: 'mifosx-saving-product-accounting-step', selector: 'mifosx-saving-product-accounting-step',
templateUrl: './saving-product-accounting-step.component.html', templateUrl: './saving-product-accounting-step.component.html',
styleUrls: ['./saving-product-accounting-step.component.scss'], styleUrls: ['./saving-product-accounting-step.component.scss'],
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
FormsModule,
ReactiveFormsModule,
MatRadioGroup, MatRadioGroup,
MatRadioButton, MatRadioButton,
GlAccountSelectorComponent, GlAccountSelectorComponent
MatCheckbox,
FaIconComponent,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatIconButton,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
FindPipe
] ]
}) })
export class SavingProductAccountingStepComponent implements OnInit { export class SavingProductAccountingStepComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder); private formBuilder = inject(UntypedFormBuilder);
private dialog = inject(MatDialog); private accounting = inject(Accounting);
private translateService = inject(TranslateService);
@Input() savingProductsTemplate: any; @Input() savingProductsTemplate: any;
@Input() accountingRuleData: any; @Input() accountingRuleData: any;
@Input() isDormancyTrackingActive: UntypedFormControl; @Input() isDormancyTrackingActive: any;
@Input() savingProductFormValid: boolean; @Input() savingProductFormValid: boolean;
@Input() allowOverdraft: UntypedFormControl; @Input() allowOverdraft: any;
savingProductAccountingForm: UntypedFormGroup; savingProductAccountingForm: UntypedFormGroup;
liabilityAccountOptions: GLAccount[] = [];
chargeData: any; expenseAccountOptions: GLAccount[] = [];
penaltyData: any; incomeAccountOptions: GLAccount[] = [];
paymentTypeData: any; assetAccountOptions: GLAccount[] = [];
assetAccountData: any; accountingSectionConfigs: AccountingSectionConfig[] = [];
incomeAccountData: any;
expenseAccountData: any;
liabilityAccountData: any;
incomeAndLiabilityAccountData: any;
combinedAccountData: any[];
paymentFundSourceDisplayedColumns: string[] = [
'paymentTypeId',
'fundSourceAccountId',
'actions'
];
feesPenaltyIncomeDisplayedColumns: string[] = [
'chargeId',
'incomeAccountId',
'actions'
];
constructor() { constructor() {
this.createsavingProductAccountingForm(); this.createSavingProductAccountingForm();
this.setConditionalControls(); this.setConditionalControls();
} }
ngOnInit() { ngOnInit() {
this.chargeData = this.savingProductsTemplate.chargeOptions || []; const mappings = this.savingProductsTemplate?.accountingMappings || {};
this.penaltyData = this.savingProductsTemplate.penaltyOptions || []; const accountingMappingOptions = this.savingProductsTemplate?.accountingMappingOptions || {};
this.paymentTypeData = this.savingProductsTemplate.paymentTypeOptions || []; const accountingRuleId = this.savingProductsTemplate?.accountingRule?.id || 1;
this.assetAccountData = this.savingProductsTemplate.accountingMappingOptions.assetAccountOptions || []; this.assetAccountOptions = accountingMappingOptions.assetAccountOptions || [];
this.incomeAccountData = this.savingProductsTemplate.accountingMappingOptions.incomeAccountOptions || []; this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || [];
this.expenseAccountData = this.savingProductsTemplate.accountingMappingOptions.expenseAccountOptions || []; this.expenseAccountOptions = accountingMappingOptions.expenseAccountOptions || [];
this.liabilityAccountData = this.savingProductsTemplate.accountingMappingOptions.liabilityAccountOptions || []; this.incomeAccountOptions = accountingMappingOptions.incomeAccountOptions || [];
this.combinedAccountData = [ this.buildAccountingSectionConfigs();
...this.assetAccountData, this.savingProductAccountingForm.patchValue({
...this.incomeAccountData, accountingRule: accountingRuleId
...this.expenseAccountData,
...this.liabilityAccountData
];
this.combinedAccountData.sort((a, b) => {
const valueA = a.name.toLowerCase();
const valueB = b.name.toLowerCase();
if (valueA < valueB) {
return -1;
}
if (valueA > valueB) {
return 1;
}
return 0;
}); });
this.syncAccountingControls(accountingRuleId);
this.patchAccountingValues(mappings);
this.setOverdraftControl();
this.setDormancyControl();
}
createSavingProductAccountingForm() {
this.savingProductAccountingForm = this.formBuilder.group({
accountingRule: [1],
assetsOtherGlMappings: this.formBuilder.array([]),
liabilitiesOtherGlMappings: this.formBuilder.array([]),
expensesOtherGlMappings: this.formBuilder.array([]),
incomeOtherGlMappings: this.formBuilder.array([])
});
}
private setConditionalControls() {
this.savingProductAccountingForm.get('accountingRule')?.valueChanges.subscribe((accountingRule: number) => {
this.syncAccountingControls(accountingRule);
});
}
private patchAccountingValues(mappings: any) {
this.savingProductAccountingForm.patchValue({ this.savingProductAccountingForm.patchValue({
accountingRule: this.savingProductsTemplate.accountingRule.id savingsReferenceAccountId: mappings.savingsReferenceAccount?.id || '',
savingsControlAccountId: mappings.savingsControlAccount?.id || '',
transfersInSuspenseAccountId: mappings.transfersInSuspenseAccount?.id || '',
interestOnSavingsAccountId: mappings.interestOnSavingsAccount?.id || '',
incomeFromFeeAccountId: mappings.incomeFromFeeAccount?.id || '',
incomeFromPenaltyAccountId: mappings.incomeFromPenaltyAccount?.id || '',
incomeFromInterestId: mappings.incomeFromInterest?.id || '',
overdraftPortfolioControlId: mappings.overdraftPortfolioControl?.id || '',
writeOffAccountId: mappings.writeOffAccount?.id || '',
feesReceivableAccountId: mappings.feeReceivableAccount?.id || '',
penaltiesReceivableAccountId: mappings.penaltyReceivableAccount?.id || '',
interestPayableAccountId: mappings.interestPayableAccount?.id || '',
interestReceivableAccountId: mappings.interestReceivableAccount?.id || '',
escheatLiabilityId: mappings.escheatLiabilityAccount?.id || this.liabilityAccountOptions[0]?.id || ''
}); });
if (this.isCashOrAccrualAccounting()) { if (this.isCashOrAccrualAccounting()) {
this.savingProductAccountingForm.patchValue({ this.setOtherGlMappings(
savingsReferenceAccountId: this.savingProductsTemplate.accountingMappings.savingsReferenceAccount.id, 'assetsOtherGlMappings',
overdraftPortfolioControlId: this.savingProductsTemplate.accountingMappings.overdraftPortfolioControl.id, mappings.otherGlMappings?.assets || [],
savingsControlAccountId: this.savingProductsTemplate.accountingMappings.savingsControlAccount.id, this.assetAccountOptions
transfersInSuspenseAccountId: this.savingProductsTemplate.accountingMappings.transfersInSuspenseAccount.id, );
interestOnSavingsAccountId: this.savingProductsTemplate.accountingMappings.interestOnSavingsAccount.id, this.setOtherGlMappings(
writeOffAccountId: this.savingProductsTemplate.accountingMappings.writeOffAccount.id, 'liabilitiesOtherGlMappings',
incomeFromFeeAccountId: this.savingProductsTemplate.accountingMappings.incomeFromFeeAccount.id, mappings.otherGlMappings?.liabilities || [],
incomeFromPenaltyAccountId: this.savingProductsTemplate.accountingMappings.incomeFromPenaltyAccount.id, this.liabilityAccountOptions
incomeFromInterestId: this.savingProductsTemplate.accountingMappings.incomeFromInterest.id, );
advancedAccountingRules: this.setOtherGlMappings(
this.savingProductsTemplate.paymentChannelToFundSourceMappings || 'expensesOtherGlMappings',
this.savingProductsTemplate.feeToIncomeAccountMappings || mappings.otherGlMappings?.expenses || [],
this.savingProductsTemplate.penaltyToIncomeAccountMappings || this.expenseAccountOptions
this.savingProductsTemplate.accrualCharges );
? true this.setOtherGlMappings(
: false 'incomeOtherGlMappings',
}); mappings.otherGlMappings?.income || [],
this.incomeAccountOptions
);
}
}
if (this.isAccrualAccounting()) { private createOtherGlMappingGroup(otherGlMapping: Partial<OtherGlMappingValue> = {}) {
this.savingProductAccountingForm.patchValue({ return this.formBuilder.group({
feesReceivableAccountId: this.savingProductsTemplate.accountingMappings.feeReceivableAccount.id, accountName: [
penaltiesReceivableAccountId: this.savingProductsTemplate.accountingMappings.penaltyReceivableAccount.id, otherGlMapping.accountName || '',
interestReceivableAccountId: Validators.required
this.savingProductsTemplate.accountingMappings.interestReceivableAccount == null || undefined ],
? '' glAccountId: [
: this.savingProductsTemplate.accountingMappings.interestReceivableAccount.id, otherGlMapping.glAccountId || '',
interestPayableAccountId: this.savingProductsTemplate.accountingMappings.interestPayableAccount.id Validators.required
]
}); });
} }
if (this.isDormancyTrackingActive.value) { getOtherGlMappingsArray(controlName: string): UntypedFormArray {
this.savingProductAccountingForm.patchValue({ return this.savingProductAccountingForm.get(controlName) as UntypedFormArray;
escheatLiabilityId: this.savingProductsTemplate.accountingMappings.escheatLiabilityAccount.id
});
}
this.savingProductAccountingForm.setControl(
'paymentChannelToFundSourceMappings',
this.formBuilder.array(
(this.savingProductsTemplate.paymentChannelToFundSourceMappings || []).map((paymentFundSource: any) => ({
paymentTypeId: paymentFundSource.paymentType.id,
fundSourceAccountId: paymentFundSource.fundSourceAccount.id
}))
)
);
this.savingProductAccountingForm.setControl(
'feeToIncomeAccountMappings',
this.formBuilder.array(
(this.savingProductsTemplate.feeToIncomeAccountMappings || []).map((feesIncome: any) => ({
chargeId: feesIncome.charge.id,
incomeAccountId: feesIncome.incomeAccount.id
}))
)
);
this.savingProductAccountingForm.setControl(
'penaltyToIncomeAccountMappings',
this.formBuilder.array(
(this.savingProductsTemplate.penaltyToIncomeAccountMappings || []).map((penaltyIncome: any) => ({
chargeId: penaltyIncome.charge.id,
incomeAccountId: penaltyIncome.incomeAccount.id
}))
)
);
}
} }
createsavingProductAccountingForm() { private setOtherGlMappings(controlName: string, otherGlMappings: any[], options: GLAccount[]) {
this.savingProductAccountingForm = this.formBuilder.group({ const otherGlMappingsArray = this.getOtherGlMappingsArray(controlName);
accountingRule: [1] otherGlMappingsArray.clear();
}); (otherGlMappings || []).forEach((otherGlMapping) => {
} otherGlMappingsArray.push(
this.createOtherGlMappingGroup({
setConditionalControls() { accountName: otherGlMapping.accountName,
this.savingProductAccountingForm.get('accountingRule').valueChanges.subscribe((accountingRule: any) => { glAccountId:
if (accountingRule === 2 || accountingRule === 3) { otherGlMapping.glAccountId || options.find((option) => option.glCode === otherGlMapping.glCode)?.id || null
this.savingProductAccountingForm.addControl(
'savingsReferenceAccountId',
new UntypedFormControl('', Validators.required)
);
this.savingProductAccountingForm.addControl(
'overdraftPortfolioControlId',
new UntypedFormControl('', Validators.required)
);
this.savingProductAccountingForm.addControl(
'savingsControlAccountId',
new UntypedFormControl('', Validators.required)
);
this.savingProductAccountingForm.addControl(
'transfersInSuspenseAccountId',
new UntypedFormControl('', Validators.required)
);
this.savingProductAccountingForm.addControl(
'interestOnSavingsAccountId',
new UntypedFormControl('', Validators.required)
);
this.savingProductAccountingForm.addControl(
'writeOffAccountId',
new UntypedFormControl('', Validators.required)
);
this.savingProductAccountingForm.addControl(
'incomeFromFeeAccountId',
new UntypedFormControl('', Validators.required)
);
this.savingProductAccountingForm.addControl(
'incomeFromPenaltyAccountId',
new UntypedFormControl('', Validators.required)
);
this.savingProductAccountingForm.addControl(
'incomeFromInterestId',
new UntypedFormControl('', Validators.required)
);
this.savingProductAccountingForm.addControl('advancedAccountingRules', new UntypedFormControl(false));
if (accountingRule === 3) {
this.savingProductAccountingForm.addControl(
'feesReceivableAccountId',
new UntypedFormControl('', Validators.required)
);
this.savingProductAccountingForm.addControl(
'penaltiesReceivableAccountId',
new UntypedFormControl('', Validators.required)
);
if (this.allowOverdraft.value) {
this.savingProductAccountingForm.addControl('interestReceivableAccountId', new UntypedFormControl(''));
}
this.allowOverdraft.valueChanges.subscribe((allowOverdraft: boolean) => {
if (allowOverdraft) {
this.savingProductAccountingForm.addControl('interestReceivableAccountId', new UntypedFormControl(''));
} else {
this.savingProductAccountingForm.removeControl('interestReceivableAccountId');
}
});
this.savingProductAccountingForm.addControl(
'interestPayableAccountId',
new UntypedFormControl('', Validators.required)
);
}
if (accountingRule === 2) {
this.savingProductAccountingForm.removeControl('feesReceivableAccountId');
this.savingProductAccountingForm.removeControl('penaltiesReceivableAccountId');
this.savingProductAccountingForm.removeControl('interestPayableAccountId');
}
if (this.isDormancyTrackingActive.value) {
this.savingProductAccountingForm.addControl(
'escheatLiabilityId',
new UntypedFormControl('', Validators.required)
);
}
this.isDormancyTrackingActive.valueChanges.subscribe((isDormancyTrackingActive: boolean) => {
if (isDormancyTrackingActive) {
this.savingProductAccountingForm.addControl(
'escheatLiabilityId',
new UntypedFormControl('', Validators.required)
);
} else {
this.savingProductAccountingForm.removeControl('escheatLiabilityId');
}
});
this.savingProductAccountingForm
.get('advancedAccountingRules')
.valueChanges.subscribe((advancedAccountingRules: boolean) => {
if (advancedAccountingRules) {
this.savingProductAccountingForm.addControl(
'paymentChannelToFundSourceMappings',
this.formBuilder.array([])
);
this.savingProductAccountingForm.addControl('feeToIncomeAccountMappings', this.formBuilder.array([]));
this.savingProductAccountingForm.addControl('penaltyToIncomeAccountMappings', this.formBuilder.array([]));
} else {
this.savingProductAccountingForm.removeControl('paymentChannelToFundSourceMappings');
this.savingProductAccountingForm.removeControl('feeToIncomeAccountMappings');
this.savingProductAccountingForm.removeControl('penaltyToIncomeAccountMappings');
}
});
} else {
this.savingProductAccountingForm.removeControl('savingsReferenceAccountId');
this.savingProductAccountingForm.removeControl('overdraftPortfolioControlId');
this.savingProductAccountingForm.removeControl('savingsControlAccountId');
this.savingProductAccountingForm.removeControl('transfersInSuspenseAccountId');
this.savingProductAccountingForm.removeControl('interestOnSavingsAccountId');
this.savingProductAccountingForm.removeControl('writeOffAccountId');
this.savingProductAccountingForm.removeControl('incomeFromFeeAccountId');
this.savingProductAccountingForm.removeControl('incomeFromPenaltyAccountId');
this.savingProductAccountingForm.removeControl('incomeFromInterestId');
this.savingProductAccountingForm.removeControl('advancedAccountingRules');
this.savingProductAccountingForm.removeControl('escheatLiabilityId');
this.savingProductAccountingForm.removeControl('feesReceivableAccountId');
this.savingProductAccountingForm.removeControl('penaltiesReceivableAccountId');
this.savingProductAccountingForm.removeControl('interestReceivableAccountId');
this.savingProductAccountingForm.removeControl('interestPayableAccountId');
}
});
}
get paymentChannelToFundSourceMappings(): UntypedFormArray {
return this.savingProductAccountingForm.get('paymentChannelToFundSourceMappings') as UntypedFormArray;
}
get feeToIncomeAccountMappings(): UntypedFormArray {
return this.savingProductAccountingForm.get('feeToIncomeAccountMappings') as UntypedFormArray;
}
get penaltyToIncomeAccountMappings(): UntypedFormArray {
return this.savingProductAccountingForm.get('penaltyToIncomeAccountMappings') as UntypedFormArray;
}
setSavingProductAccountingFormDirty() {
if (this.savingProductAccountingForm.pristine) {
this.savingProductAccountingForm.markAsDirty();
}
}
existCharges(): boolean {
return this.chargeData.length > 0;
}
add(formType: string, formArray: UntypedFormArray) {
const data = { ...this.getData(formType), pristine: false };
const dialogRef = this.dialog.open(FormDialogComponent, { data, width: '20rem' });
dialogRef.afterClosed().subscribe((response: any) => {
if (response.data) {
formArray.push(response.data);
this.setSavingProductAccountingFormDirty();
}
});
}
edit(formType: string, formArray: UntypedFormArray, index: number) {
const data = { ...this.getData(formType, formArray.at(index).value), layout: { addButtonText: 'Edit' } };
const dialogRef = this.dialog.open(FormDialogComponent, { data });
dialogRef.afterClosed().subscribe((response: any) => {
if (response.data) {
formArray.at(index).patchValue(response.data.value);
this.setSavingProductAccountingFormDirty();
}
});
}
delete(formArray: UntypedFormArray, index: number) {
const dialogRef = this.dialog.open(DeleteDialogComponent, {
data: { deleteContext: `this` }
});
dialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) {
formArray.removeAt(index);
this.setSavingProductAccountingFormDirty();
}
});
}
getData(formType: string, values?: any) {
switch (formType) {
case 'PaymentFundSource':
return {
title: this.translateService.instant('labels.heading.Configure Fund Sources for Payment Channels'),
formfields: this.getPaymentFundSourceFormfields(values)
};
case 'FeesIncome':
return {
title: this.translateService.instant('labels.heading.Map Fees to Specific Income Accounts'),
formfields: this.getFeesIncomeFormfields(values)
};
case 'PenaltyIncome':
return {
title: this.translateService.instant('labels.heading.Map Penalties to Specific Income Accounts'),
formfields: this.getPenaltyIncomeFormfields(values)
};
}
}
getPaymentFundSourceFormfields(values?: any) {
const formfields: FormfieldBase[] = [
new SelectBase({
controlName: 'paymentTypeId',
label: this.translateService.instant('labels.inputs.Payment Type'),
value: values ? values.paymentTypeId : this.paymentTypeData[0].id,
options: { label: 'name', value: 'id', data: this.paymentTypeData },
required: true,
order: 1
}),
new SelectBase({
controlName: 'fundSourceAccountId',
label: this.translateService.instant('labels.inputs.Fund Source'),
value: values ? values.fundSourceAccountId : this.combinedAccountData[0].id,
options: { label: 'name', value: 'id', data: this.combinedAccountData },
required: true,
order: 2
}) })
]; );
return formfields; });
} }
getFeesIncomeFormfields(values?: any) { addOtherGlMapping(controlName: string) {
const formfields: FormfieldBase[] = [ this.getOtherGlMappingsArray(controlName).push(this.createOtherGlMappingGroup());
new SelectBase({
controlName: 'chargeId',
label: this.translateService.instant('labels.inputs.Fees'),
value: values ? values.chargeId : this.chargeData[0].id,
options: { label: 'name', value: 'id', data: this.chargeData },
required: true,
order: 1
}),
new SelectBase({
controlName: 'incomeAccountId',
label: this.translateService.instant('labels.inputs.Income Account'),
value: values ? values.incomeAccountId : this.incomeAccountData[0].id,
options: { label: 'name', value: 'id', data: this.incomeAccountData },
required: true,
order: 2
})
];
return formfields;
} }
getPenaltyIncomeFormfields(values?: any) { removeOtherGlMapping(controlName: string, index: number) {
const formfields: FormfieldBase[] = [ this.getOtherGlMappingsArray(controlName).removeAt(index);
new SelectBase({ }
controlName: 'chargeId',
label: this.translateService.instant('labels.inputs.Penalty'), getOtherGlMappingControl(controlName: string, index: number, fieldName: string): UntypedFormControl {
value: values ? values.chargeId : this.penaltyData[0].id, return this.getOtherGlMappingsArray(controlName).at(index).get(fieldName) as UntypedFormControl;
options: { label: 'name', value: 'id', data: this.penaltyData }, }
required: true,
order: 1 private clearOtherGlMappings() {
}), [
new SelectBase({ 'assetsOtherGlMappings',
controlName: 'incomeAccountId', 'liabilitiesOtherGlMappings',
label: this.translateService.instant('labels.inputs.Income Account'), 'expensesOtherGlMappings',
value: values ? values.incomeAccountId : this.incomeAccountData[0].id, 'incomeOtherGlMappings'
options: { label: 'name', value: 'id', data: this.incomeAccountData }, ].forEach((controlName) => this.getOtherGlMappingsArray(controlName).clear());
required: true, }
order: 2
}) private setOverdraftControl() {
]; this.allowOverdraft?.valueChanges?.subscribe(() => {
return formfields; this.syncAccountingControls(this.savingProductAccountingForm.value.accountingRule);
});
}
private setDormancyControl() {
this.isDormancyTrackingActive?.valueChanges?.subscribe(() => {
this.syncAccountingControls(this.savingProductAccountingForm.value.accountingRule);
});
}
private getOtherGlMappings() {
const mapOtherGlMappings = (controlName: string, options: GLAccount[]) =>
this.getOtherGlMappingsArray(controlName)
.value.filter((item: OtherGlMappingValue) => !!item.accountName || !!item.glAccountId)
.map((item: OtherGlMappingValue) => ({
accountName: item.accountName,
glCode: options.find((option) => option.id === item.glAccountId)?.glCode || ''
}))
.filter((item: { accountName: string; glCode: string }) => !!item.accountName || !!item.glCode);
const otherGlMappings = {
assets: mapOtherGlMappings('assetsOtherGlMappings', this.assetAccountOptions),
liabilities: mapOtherGlMappings('liabilitiesOtherGlMappings', this.liabilityAccountOptions),
expenses: mapOtherGlMappings('expensesOtherGlMappings', this.expenseAccountOptions),
income: mapOtherGlMappings('incomeOtherGlMappings', this.incomeAccountOptions)
};
return Object.values(otherGlMappings).some((value) => value.length) ? otherGlMappings : null;
}
isDormancyTrackingEnabled(): boolean {
return this.isDormancyTrackingActive?.value ?? this.isDormancyTrackingActive ?? false;
}
private syncAccountingControls(accountingRule: number) {
const isDormancyActive = this.isDormancyTrackingEnabled();
const addControl = (controlName: string, required = false) => {
if (!this.savingProductAccountingForm.contains(controlName)) {
this.savingProductAccountingForm.addControl(
controlName,
new UntypedFormControl('', required ? Validators.required : undefined)
);
} else if (required) {
this.savingProductAccountingForm.get(controlName)?.setValidators(Validators.required);
this.savingProductAccountingForm.get(controlName)?.updateValueAndValidity({ emitEvent: false });
}
};
const removeControl = (controlName: string) => {
if (this.savingProductAccountingForm.contains(controlName)) {
this.savingProductAccountingForm.removeControl(controlName);
}
};
if (this.accounting.isCashOrAccrualAccountingRuleId(accountingRule)) {
addControl('savingsReferenceAccountId', true);
addControl('savingsControlAccountId', true);
addControl('transfersInSuspenseAccountId', true);
addControl('interestOnSavingsAccountId', true);
addControl('incomeFromFeeAccountId', true);
addControl('incomeFromPenaltyAccountId', true);
addControl('incomeFromInterestId', true);
addControl('writeOffAccountId', true);
addControl('overdraftPortfolioControlId', false);
if (this.accounting.isAccrualAccountingRuleId(accountingRule)) {
addControl('feesReceivableAccountId', true);
addControl('penaltiesReceivableAccountId', true);
addControl('interestPayableAccountId', true);
addControl('interestReceivableAccountId', false);
} else {
removeControl('feesReceivableAccountId');
removeControl('penaltiesReceivableAccountId');
removeControl('interestPayableAccountId');
removeControl('interestReceivableAccountId');
}
if (isDormancyActive) {
addControl('escheatLiabilityId', false);
} else {
removeControl('escheatLiabilityId');
}
} else {
removeControl('savingsReferenceAccountId');
removeControl('savingsControlAccountId');
removeControl('transfersInSuspenseAccountId');
removeControl('interestOnSavingsAccountId');
removeControl('incomeFromFeeAccountId');
removeControl('incomeFromPenaltyAccountId');
removeControl('incomeFromInterestId');
removeControl('writeOffAccountId');
removeControl('overdraftPortfolioControlId');
removeControl('feesReceivableAccountId');
removeControl('penaltiesReceivableAccountId');
removeControl('interestPayableAccountId');
removeControl('interestReceivableAccountId');
removeControl('escheatLiabilityId');
this.clearOtherGlMappings();
}
} }
get savingProductAccounting() { get savingProductAccounting() {
return this.savingProductAccountingForm.value; const {
assetsOtherGlMappings,
liabilitiesOtherGlMappings,
expensesOtherGlMappings,
incomeOtherGlMappings,
...savingProductAccounting
} = this.savingProductAccountingForm.value;
const otherGlMappings = this.getOtherGlMappings();
return {
...savingProductAccounting,
...(otherGlMappings ? { otherGlMappings } : {})
};
} }
isCashOrAccrualAccounting(): boolean { isCashOrAccrualAccounting(): boolean {
return ( return this.accounting.isCashOrAccrualAccountingRuleId(this.savingProductAccountingForm.value.accountingRule);
this.savingProductAccountingForm.value.accountingRule === 2 ||
this.savingProductAccountingForm.value.accountingRule === 3
);
} }
isAccrualAccounting(): boolean { isAccrualAccounting(): boolean {
return this.savingProductAccountingForm.value.accountingRule === 3; return this.accounting.isAccrualAccountingRuleId(this.savingProductAccountingForm.value.accountingRule);
}
buildAccountingSectionConfigs() {
this.accountingSectionConfigs = [
{
key: 'assets',
title: 'Assets',
pillLabel: 'Asset',
pillClass: 'green',
rows: [
{
key: 'savingsReferenceAccountId',
label: 'Savings Reference Account',
inputLabel: 'Savings reference account',
controlName: 'savingsReferenceAccountId',
options: this.assetAccountOptions,
required: true
},
{
key: 'overdraftPortfolioControlId',
label: 'Overdraft Portfolio Control',
inputLabel: 'Overdraft portfolio control',
controlName: 'overdraftPortfolioControlId',
options: this.assetAccountOptions
},
{
key: 'interestReceivableAccountId',
label: 'Interest Receivable Account',
inputLabel: 'Interest receivable account',
controlName: 'interestReceivableAccountId',
options: this.assetAccountOptions,
accrualOnly: true
},
{
key: 'feesReceivableAccountId',
label: 'Fees Receivable Account',
inputLabel: 'Fees receivable account',
controlName: 'feesReceivableAccountId',
options: this.assetAccountOptions,
accrualOnly: true
},
{
key: 'penaltiesReceivableAccountId',
label: 'Penalties Receivable Account',
inputLabel: 'Penalties receivable account',
controlName: 'penaltiesReceivableAccountId',
options: this.assetAccountOptions,
accrualOnly: true
}
],
otherMappingsControlName: 'assetsOtherGlMappings',
otherAccountOptions: this.assetAccountOptions
},
{
key: 'liabilities',
title: 'Liabilities',
pillLabel: 'Liability',
pillClass: 'green',
rows: [
{
key: 'savingsControlAccountId',
label: 'Savings Control Account',
inputLabel: 'Savings control account',
controlName: 'savingsControlAccountId',
options: this.liabilityAccountOptions,
required: true
},
{
key: 'transfersInSuspenseAccountId',
label: 'Transfers In Suspense Account',
inputLabel: 'Transfers in suspense account',
controlName: 'transfersInSuspenseAccountId',
options: this.liabilityAccountOptions,
required: true
},
{
key: 'interestPayableAccountId',
label: 'Interest Payable Account',
inputLabel: 'Interest payable account',
controlName: 'interestPayableAccountId',
options: this.liabilityAccountOptions,
accrualOnly: true
},
{
key: 'escheatLiabilityId',
label: 'Escheat Liability Account',
inputLabel: 'Escheat liability account',
controlName: 'escheatLiabilityId',
options: this.liabilityAccountOptions,
dormancyOnly: true
}
],
otherMappingsControlName: 'liabilitiesOtherGlMappings',
otherAccountOptions: this.liabilityAccountOptions
},
{
key: 'expenses',
title: 'Expenses',
pillLabel: 'Expense',
pillClass: 'amber',
rows: [
{
key: 'interestOnSavingsAccountId',
label: 'Interest On Savings Account',
inputLabel: 'Interest on savings account',
controlName: 'interestOnSavingsAccountId',
options: this.expenseAccountOptions,
required: true
},
{
key: 'writeOffAccountId',
label: 'Write Off Account',
inputLabel: 'Write off account',
controlName: 'writeOffAccountId',
options: this.expenseAccountOptions,
required: true
}
],
otherMappingsControlName: 'expensesOtherGlMappings',
otherAccountOptions: this.expenseAccountOptions
},
{
key: 'income',
title: 'Income',
pillLabel: 'Income',
pillClass: 'blue',
rows: [
{
key: 'incomeFromFeeAccountId',
label: 'Income From Fees',
inputLabel: 'Income from fees',
controlName: 'incomeFromFeeAccountId',
options: this.incomeAccountOptions,
required: true
},
{
key: 'incomeFromPenaltyAccountId',
label: 'Income From Penalties',
inputLabel: 'Income from penalties',
controlName: 'incomeFromPenaltyAccountId',
options: this.incomeAccountOptions,
required: true
},
{
key: 'incomeFromInterestId',
label: 'Income From Interest',
inputLabel: 'Income from interest',
controlName: 'incomeFromInterestId',
options: this.incomeAccountOptions,
required: true
}
],
otherMappingsControlName: 'incomeOtherGlMappings',
otherAccountOptions: this.incomeAccountOptions
}
];
} }
} }
@@ -65,7 +65,7 @@ $gray-900: #202124;
box-shadow: 0 1px 3px rgba($primary, 0.3) !important; box-shadow: 0 1px 3px rgba($primary, 0.3) !important;
} }
@media (max-width: 600px) { @media (width <= 600px) {
flex-direction: column; flex-direction: column;
&__select { &__select {
@@ -10,8 +10,9 @@
<div class="spc-section"> <div class="spc-section">
<div class="spc-section__header"> <div class="spc-section__header">
<i class="fas fa-coins"></i> <i class="fas fa-coins"></i>
<h3>{{ 'labels.inputs.Currency' | translate }}</h3> <span>Currency &amp; Account Status Configuration</span>
</div> </div>
<div class="spc-body">
<div class="spc-grid"> <div class="spc-grid">
<mat-form-field appearance="outline" class="spc-field"> <mat-form-field appearance="outline" class="spc-field">
<mat-label>{{ 'labels.inputs.Currency' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Currency' | translate }}</mat-label>
@@ -26,6 +27,7 @@
</mat-option> </mat-option>
} }
</mat-select> </mat-select>
<mat-hint>Default: Indian Rupee - auto-set for domestic products</mat-hint>
<mat-error> <mat-error>
{{ 'labels.inputs.Currency' | translate }} {{ 'labels.commons.is' | translate }} {{ 'labels.inputs.Currency' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong> <strong>{{ 'labels.commons.required' | translate }}</strong>
@@ -34,50 +36,43 @@
<mat-form-field appearance="outline" class="spc-field"> <mat-form-field appearance="outline" class="spc-field">
<mat-label>{{ 'labels.inputs.Decimal Places' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Decimal Places' | translate }}</mat-label>
<input <mat-select
type="number"
min="0"
matInput
matTooltip="{{ 'tooltips.Track and report saving account' | translate }}"
formControlName="digitsAfterDecimal" formControlName="digitsAfterDecimal"
matTooltip="{{ 'tooltips.Track and report saving account' | translate }}"
required required
/> >
@for (places of decimalPlacesData; track places) {
<mat-option [value]="places">{{ places }}</mat-option>
}
</mat-select>
<mat-error> <mat-error>
{{ 'labels.inputs.Decimal Places' | translate }} {{ 'labels.commons.is' | translate }} {{ 'labels.inputs.Decimal Places' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong> <strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="spc-alert">
<div class="spc-alert__content">
<h4>Dormancy Alert</h4>
<p>Enable automated notifications when an account becomes inactive based on product rules.</p>
</div>
<mat-radio-group class="spc-alert__choices" formControlName="dormancyAlert">
<mat-radio-button [value]="true">yes</mat-radio-button>
<mat-radio-button [value]="false">no</mat-radio-button>
</mat-radio-group>
</div> </div>
<div class="spc-section"> <div class="spc-alert">
<div class="spc-section__header"> <div class="spc-alert__content">
<i class="fas fa-calculator"></i> <h4>Nomination Facility</h4>
<h3>{{ 'labels.inputs.Multiples' | translate }}</h3> <p>Allow account holders to designate individuals to receive funds in the event of account holder's death.</p>
</div> </div>
<div class="spc-grid"> <mat-radio-group class="spc-alert__choices" formControlName="nominationFacility">
<div class="spc-field--full spc-checkbox-row"> <mat-radio-button [value]="true">yes</mat-radio-button>
<mat-checkbox labelPosition="before" formControlName="setMultiples"> <mat-radio-button [value]="false">no</mat-radio-button>
{{ 'labels.inputs.Set the saving installment in multiples of' | translate }} </mat-radio-group>
</mat-checkbox>
</div> </div>
@if (savingProductCurrencyForm.value.setMultiples) {
<mat-form-field appearance="outline" class="spc-field">
<mat-label>{{ 'labels.inputs.Currency in multiples of' | translate }}</mat-label>
<input
type="number"
min="1"
matInput
matTooltip="{{ 'tooltips.Amount to be rounded off' | translate }}"
formControlName="inMultiplesOf"
/>
<mat-error>
{{ 'labels.inputs.Currency in multiples of' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
}
</div> </div>
</div> </div>
</form> </form>
@@ -44,27 +44,71 @@ $gray-900: #202124;
} }
} }
.spc-section__badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
background: #eff6ff;
font-size: 14px;
}
.spc-grid { .spc-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 4px 16px; gap: 4px 16px;
padding: 16px 24px; padding: 16px 24px;
@media (max-width: 600px) { @media (width <= 600px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
.spc-body {
background: #fff;
}
.spc-field { .spc-field {
width: 100%; width: 100%;
&--full {
grid-column: 1 / -1;
}
} }
.spc-checkbox-row { .spc-alert {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 4px 0; justify-content: space-between;
gap: 24px;
padding: 18px 24px 24px;
border-top: 1px solid $border;
background: #f8fafc;
@media (width <= 760px) {
flex-direction: column;
align-items: flex-start;
}
&__content {
h4 {
margin: 0 0 6px;
font-size: 14px;
font-weight: 600;
color: $gray-900;
}
p {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: #64748b;
max-width: 540px;
}
}
&__choices {
display: flex;
align-items: center;
gap: 18px;
flex-wrap: wrap;
}
} }
@@ -6,11 +6,10 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import { Component, OnInit, Input, inject, DestroyRef } from '@angular/core'; import { Component, OnInit, Input, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatTooltip } from '@angular/material/tooltip'; import { MatTooltip } from '@angular/material/tooltip';
import { MatCheckbox } from '@angular/material/checkbox'; import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -21,19 +20,24 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
MatTooltip, MatTooltip,
FaIconComponent, MatRadioGroup,
MatCheckbox MatRadioButton,
FaIconComponent
] ]
}) })
export class SavingProductCurrencyStepComponent implements OnInit { export class SavingProductCurrencyStepComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder); private formBuilder = inject(UntypedFormBuilder);
private destroyRef = inject(DestroyRef);
@Input() savingProductsTemplate: any; @Input() savingProductsTemplate: any;
savingProductCurrencyForm: UntypedFormGroup; savingProductCurrencyForm: UntypedFormGroup;
currencyData: any; currencyData: any;
decimalPlacesData = [
2,
0,
3
];
constructor() { constructor() {
this.createSavingProductCurrencyForm(); this.createSavingProductCurrencyForm();
@@ -43,13 +47,11 @@ export class SavingProductCurrencyStepComponent implements OnInit {
this.currencyData = this.savingProductsTemplate.currencyOptions; this.currencyData = this.savingProductsTemplate.currencyOptions;
this.savingProductCurrencyForm.patchValue({ this.savingProductCurrencyForm.patchValue({
currencyCode: this.savingProductsTemplate.currency.code || this.currencyData[0].code, currencyCode: this.savingProductsTemplate.currency?.code || this.currencyData?.[0]?.code || '',
digitsAfterDecimal: this.savingProductsTemplate.digitsAfterDecimal ?? '', digitsAfterDecimal: this.savingProductsTemplate.digitsAfterDecimal ?? 2,
setMultiples: !!this.savingProductsTemplate.inMultiplesOf, dormancyAlert: this.savingProductsTemplate.isDormancyTrackingActive ?? false,
inMultiplesOf: this.savingProductsTemplate.inMultiplesOf ?? '' nominationFacility: this.savingProductsTemplate.nominationFacility ?? false
}); });
this.setupConditionalValidation();
} }
createSavingProductCurrencyForm() { createSavingProductCurrencyForm() {
@@ -59,39 +61,14 @@ export class SavingProductCurrencyStepComponent implements OnInit {
Validators.required Validators.required
], ],
digitsAfterDecimal: [ digitsAfterDecimal: [
'', 2,
[ [
Validators.required, Validators.required,
Validators.min(0) Validators.min(0)
] ]
], ],
setMultiples: [false], dormancyAlert: [false],
inMultiplesOf: [''] nominationFacility: [false]
});
}
setupConditionalValidation() {
const inMultiplesOfControl = this.savingProductCurrencyForm.get('inMultiplesOf');
const setMultiplesControl = this.savingProductCurrencyForm.get('setMultiples');
if (setMultiplesControl?.value) {
inMultiplesOfControl?.setValidators([
Validators.required,
Validators.min(1)
]);
inMultiplesOfControl?.updateValueAndValidity();
}
setMultiplesControl?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((checked) => {
if (checked) {
inMultiplesOfControl?.setValidators([
Validators.required,
Validators.min(1)
]);
} else {
inMultiplesOfControl?.clearValidators();
inMultiplesOfControl?.setValue('');
}
inMultiplesOfControl?.updateValueAndValidity();
}); });
} }
@@ -99,13 +76,12 @@ export class SavingProductCurrencyStepComponent implements OnInit {
const formValue = this.savingProductCurrencyForm.value; const formValue = this.savingProductCurrencyForm.value;
const result: any = { const result: any = {
currencyCode: formValue.currencyCode, currencyCode: formValue.currencyCode,
digitsAfterDecimal: formValue.digitsAfterDecimal digitsAfterDecimal: formValue.digitsAfterDecimal,
dormancyAlert: formValue.dormancyAlert,
isDormancyTrackingActive: formValue.dormancyAlert,
nominationFacility: formValue.nominationFacility
}; };
if (formValue.inMultiplesOf !== '' && formValue.inMultiplesOf !== null && formValue.inMultiplesOf !== undefined) {
result.inMultiplesOf = formValue.inMultiplesOf;
}
return result; return result;
} }
} }
@@ -54,4 +54,64 @@
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
<div class="spd-section">
<div class="spd-section__header">
<i class="fas fa-tags"></i>
<h3>Product Classification</h3>
</div>
<div class="spd-grid spd-grid--classification">
<mat-form-field appearance="outline" class="spd-field">
<mat-label>Account Type</mat-label>
<mat-select formControlName="accountType" required>
@for (accountType of accountTypeData; track accountType) {
<mat-option [value]="accountType">{{ accountType }}</mat-option>
}
</mat-select>
<mat-error>Account Type <strong>is required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="spd-field">
<mat-label>Customer Segment</mat-label>
<mat-select formControlName="customerSegment">
@for (customerSegment of customerSegmentData; track customerSegment) {
<mat-option [value]="customerSegment">{{ customerSegment }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="spd-field">
<mat-label>Product Code</mat-label>
<input matInput formControlName="productCode" />
</mat-form-field>
<mat-form-field appearance="outline" class="spd-field" (click)="effectiveToPicker.open()">
<mat-label>Effective To</mat-label>
<input matInput [matDatepicker]="effectiveToPicker" formControlName="effectiveTo" />
<mat-datepicker-toggle matSuffix [for]="effectiveToPicker"></mat-datepicker-toggle>
<mat-datepicker #effectiveToPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="spd-field" (click)="endDatePicker.open()">
<mat-label>End Date</mat-label>
<input matInput [matDatepicker]="endDatePicker" formControlName="endDate" />
<mat-datepicker-toggle matSuffix [for]="endDatePicker"></mat-datepicker-toggle>
<mat-datepicker #endDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="spd-field">
<mat-label>Introducer Required</mat-label>
<mat-select formControlName="introducerRequired">
@for (option of yesNoOptions; track option.value) {
<mat-option [value]="option.value">{{ option.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<div class="spd-field spd-field--full spd-checkbox-row">
<mat-checkbox formControlName="shareLinkingEnabled">Share Linking Enabled</mat-checkbox>
</div>
</div>
</div>
</form> </form>
@@ -46,11 +46,15 @@ $gray-900: #202124;
gap: 4px 16px; gap: 4px 16px;
padding: 16px 24px; padding: 16px 24px;
@media (max-width: 600px) { @media (width <= 600px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
.spd-grid--classification {
grid-template-columns: repeat(3, 1fr);
}
.spd-field { .spd-field {
width: 100%; width: 100%;
@@ -58,3 +62,9 @@ $gray-900: #202124;
grid-column: 1 / -1; grid-column: 1 / -1;
} }
} }
.spd-checkbox-row {
display: flex;
align-items: center;
min-height: 56px;
}
@@ -6,11 +6,13 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import { Component, OnInit, Input, inject } from '@angular/core'; import { Component, OnInit, OnChanges, Input, SimpleChanges, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatTooltip } from '@angular/material/tooltip'; import { MatTooltip } from '@angular/material/tooltip';
import { CdkTextareaAutosize } from '@angular/cdk/text-field'; import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { Dates } from 'app/core/utils/dates';
import { SettingsService } from 'app/settings/settings.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({ @Component({
@@ -24,12 +26,29 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
FaIconComponent FaIconComponent
] ]
}) })
export class SavingProductDetailsStepComponent implements OnInit { export class SavingProductDetailsStepComponent implements OnInit, OnChanges {
private formBuilder = inject(UntypedFormBuilder); private formBuilder = inject(UntypedFormBuilder);
private dateUtils = inject(Dates);
private settingsService = inject(SettingsService);
@Input() savingProductsTemplate: any; @Input() savingProductsTemplate: any;
savingProductDetailsForm: UntypedFormGroup; savingProductDetailsForm: UntypedFormGroup;
accountTypeData: string[] = [];
customerSegmentData: string[] = [];
yesNoOptions: Array<{
value: boolean;
label: string;
}> = [
{
value: true,
label: 'Yes'
},
{
value: false,
label: 'No'
}
];
constructor() { constructor() {
this.createSavingProductDetailsForm(); this.createSavingProductDetailsForm();
@@ -37,11 +56,13 @@ export class SavingProductDetailsStepComponent implements OnInit {
ngOnInit() { ngOnInit() {
if (this.savingProductsTemplate) { if (this.savingProductsTemplate) {
this.savingProductDetailsForm.patchValue({ this.applyTemplate(this.savingProductsTemplate);
name: this.savingProductsTemplate.name, }
shortName: this.savingProductsTemplate.shortName, }
description: this.savingProductsTemplate.description
}); ngOnChanges(changes: SimpleChanges) {
if (changes.savingProductsTemplate?.currentValue) {
this.applyTemplate(changes.savingProductsTemplate.currentValue);
} }
} }
@@ -55,11 +76,111 @@ export class SavingProductDetailsStepComponent implements OnInit {
'', '',
Validators.required Validators.required
], ],
description: [''] description: [''],
accountType: [
null,
Validators.required
],
customerSegment: [null],
productCode: ['SB-IND-001'],
effectiveDate: [''],
effectiveTo: [''],
endDate: [''],
introducerRequired: [false],
shareLinkingEnabled: [false],
nominationFacility: [false],
dormancyAlert: [false]
}); });
} }
private getOptionNames(options: any[]): string[] {
if (!Array.isArray(options)) {
return [];
}
return options
.map((option) => option?.name ?? option?.value ?? option)
.filter((value) => typeof value === 'string' && value.trim().length > 0);
}
private applyTemplate(template: any) {
const accountTypeOptions = this.getOptionNames(template.accountTypeOptions);
const customerSegmentOptions = this.getOptionNames(template.customerSegmentOptions);
const yesNoOptions = this.getYesNoOptions(template.yesNoOptions);
this.accountTypeData = accountTypeOptions;
this.customerSegmentData = customerSegmentOptions;
if (yesNoOptions.length) {
this.yesNoOptions = yesNoOptions;
}
// Use existing product data if available (when editing), otherwise use template defaults
const isEditing = !!template.id;
this.savingProductDetailsForm.patchValue({
name: isEditing ? template.name : template.name || '',
shortName: isEditing ? template.shortName : template.shortName || '',
description: template.description,
accountType: template.accountType ?? this.accountTypeData[0] ?? null,
customerSegment: template.customerSegment ?? this.customerSegmentData[0] ?? null,
productCode: template.productCode ?? 'SB-IND-001',
effectiveDate: template.effectiveDate ? new Date(template.effectiveDate) : '',
effectiveTo: template.effectiveTo ? new Date(template.effectiveTo) : '',
endDate: template.endDate ? new Date(template.endDate) : '',
introducerRequired: template.introducerRequired === true || template.introducerRequired === 'Yes',
shareLinkingEnabled: !!template.shareLinkingEnabled,
nominationFacility: !!template.nominationFacility,
dormancyAlert: !!template.dormancyAlert
});
}
private getYesNoOptions(options: any[]): Array<{ value: boolean; label: string }> {
if (!Array.isArray(options)) {
return [];
}
return options
.map((option) => {
const rawValue = option?.name ?? option?.value ?? option;
const normalized = typeof rawValue === 'string' ? rawValue.trim().toUpperCase() : rawValue;
if (normalized === 'YES') {
return {
value: true,
label: 'Yes'
};
}
if (normalized === 'NO') {
return {
value: false,
label: 'No'
};
}
return null;
})
.filter((value) => !!value);
}
get savingProductDetails() { get savingProductDetails() {
return this.savingProductDetailsForm.value; const formValue = { ...this.savingProductDetailsForm.value };
const dateFormat = this.settingsService.dateFormat;
const prevEffectiveDate: Date = this.savingProductDetailsForm.value.effectiveDate;
const prevEffectiveTo: Date = this.savingProductDetailsForm.value.effectiveTo;
const prevEndDate: Date = this.savingProductDetailsForm.value.endDate;
if (formValue.effectiveDate instanceof Date) {
formValue.effectiveDate = this.dateUtils.formatDate(prevEffectiveDate, dateFormat) || '';
}
if (formValue.effectiveTo instanceof Date) {
formValue.effectiveTo = this.dateUtils.formatDate(prevEffectiveTo, dateFormat) || '';
}
if (formValue.endDate instanceof Date) {
formValue.endDate = this.dateUtils.formatDate(prevEndDate, dateFormat) || '';
}
return formValue;
} }
} }
@@ -0,0 +1,351 @@
<!--
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="spl-section">
<div class="spl-section__header">
<span class="spl-section__icon">
<i class="fas fa-chart-bar"></i>
</span>
<div class="spl-section__title">Transaction &amp; Product Limits</div>
<div class="spl-section__badge">R&amp;D BASED DEFAULTS</div>
</div>
<div class="spl-section__body">
<div class="spl-features">
<div class="spl-features__header">Account Features (as per RBI guidelines)</div>
<div class="spl-features__grid">
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-automobile"></i>
ATM Facility
</span>
<select class="spl-select" [(ngModel)]="accountFacilityConfig.atmFacility" (ngModelChange)="markAsDirty()">
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-wallet"></i>
ATM Withdrawal
</span>
<select
class="spl-select"
[(ngModel)]="accountFacilityConfig.atmWithdrawalFacility"
(ngModelChange)="markAsDirty()"
>
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-mobile-alt"></i>
UPI Facility
</span>
<select class="spl-select" [(ngModel)]="accountFacilityConfig.upiFacility" (ngModelChange)="markAsDirty()">
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-exchange-alt"></i>
POS Transactions
</span>
<select
class="spl-select"
[(ngModel)]="accountFacilityConfig.posTransactionsFacility"
(ngModelChange)="markAsDirty()"
>
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-book"></i>
Cheque Book
</span>
<select class="spl-select" [(ngModel)]="accountFacilityConfig.chequeBook" (ngModelChange)="markAsDirty()">
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-bolt"></i>
IMPS Facility
</span>
<select class="spl-select" [(ngModel)]="accountFacilityConfig.impsFacility" (ngModelChange)="markAsDirty()">
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-sms"></i>
SMS Alerts
</span>
<select class="spl-select" [(ngModel)]="accountFacilityConfig.smsAlerts" (ngModelChange)="markAsDirty()">
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-bell"></i>
BBPS Facility
</span>
<select class="spl-select" [(ngModel)]="accountFacilityConfig.bbpsFacility" (ngModelChange)="markAsDirty()">
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-shield-alt"></i>
Insurance Cover
</span>
<select class="spl-select" [(ngModel)]="accountFacilityConfig.insuranceCover" (ngModelChange)="markAsDirty()">
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-bolt"></i>
BEPG Facility
</span>
<select class="spl-select" [(ngModel)]="accountFacilityConfig.bepgFacility" (ngModelChange)="markAsDirty()">
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-money-check-alt"></i>
NEFT Facility
</span>
<select class="spl-select" [(ngModel)]="accountFacilityConfig.neftFacility" (ngModelChange)="markAsDirty()">
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-money-check"></i>
RTGS Facility
</span>
<select class="spl-select" [(ngModel)]="accountFacilityConfig.rtgsFacility" (ngModelChange)="markAsDirty()">
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-balance-scale"></i>
Inter-Bank Total Cap
</span>
<select
class="spl-select"
[(ngModel)]="accountFacilityConfig.interBankTotalCapFacility"
(ngModelChange)="markAsDirty()"
>
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-arrow-right-arrow-left"></i>
Withdrawal Facility
</span>
<select
class="spl-select"
[(ngModel)]="accountFacilityConfig.withdrawalFacility"
(ngModelChange)="markAsDirty()"
>
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-id-card"></i>
Deposit Facility (KYC)
</span>
<select
class="spl-select"
[(ngModel)]="accountFacilityConfig.depositFacilityKyc"
(ngModelChange)="markAsDirty()"
>
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-credit-card"></i>
Virtual Debit Card
</span>
<select
class="spl-select"
[(ngModel)]="accountFacilityConfig.virtualDebitCard"
(ngModelChange)="markAsDirty()"
>
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
</div>
</div>
<div class="spl-table-wrap">
<table class="spl-table">
<colgroup>
<col class="spl-table__col--label" />
<col class="spl-table__col--value" />
<col class="spl-table__col--value" />
<col class="spl-table__col--value" />
</colgroup>
<thead>
<tr>
<th>Limit Type</th>
<th>General Limit (Daily)</th>
<th>Minor Account Limit</th>
<th>Small Saving Limit</th>
</tr>
</thead>
<tbody>
@for (row of accountFacilityLimits; track row.limitType) {
<tr>
<td>{{ row.limitType }}</td>
<td>
<input type="text" class="spl-input" [(ngModel)]="row.generalLimit" (ngModelChange)="markAsDirty()" />
</td>
<td>
<input
type="text"
class="spl-input"
[(ngModel)]="row.minorAccountLimit"
(ngModelChange)="markAsDirty()"
[disabled]="row.limitType === 'RTGS Limit (Min 2L)'"
/>
</td>
<td>
<input
type="text"
class="spl-input"
[(ngModel)]="row.smallSavingLimit"
(ngModelChange)="markAsDirty()"
[disabled]="row.limitType === 'RTGS Limit (Min 2L)'"
/>
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="spl-table-wrap spl-table-wrap--kyc">
<h4 class="mat-h4">Deposit Limit (KYC)</h4>
<table class="spl-table">
<thead>
<tr>
<th>Account Type</th>
<th>KYC Type</th>
<th>Limit</th>
</tr>
</thead>
<tbody>
<tr>
<td>General</td>
<td>
<select class="spl-select" [(ngModel)]="depositLimitKyc.general.kycType" (ngModelChange)="markAsDirty()">
<option>Full KYC</option>
<option>Min KYC</option>
<option>No KYC</option>
</select>
</td>
<td>
<input
type="text"
class="spl-input"
[(ngModel)]="depositLimitKyc.general.limit"
(ngModelChange)="markAsDirty()"
/>
</td>
</tr>
<tr>
<td>Minor</td>
<td>
<select class="spl-select" [(ngModel)]="depositLimitKyc.minor.kycType" (ngModelChange)="markAsDirty()">
<option>Guardian KYC</option>
<option>Full KYC</option>
<option>Min KYC</option>
</select>
</td>
<td>
<input
type="number"
class="spl-input"
[(ngModel)]="depositLimitKyc.minor.limit"
(ngModelChange)="markAsDirty()"
/>
</td>
</tr>
<tr>
<td>Small Saving</td>
<td>
<select
class="spl-select"
[(ngModel)]="depositLimitKyc.smallSaving.kycType"
(ngModelChange)="markAsDirty()"
>
<option>Min KYC</option>
<option>Full KYC</option>
<option>Guardian KYC</option>
</select>
</td>
<td>
<input
type="number"
class="spl-input"
[(ngModel)]="depositLimitKyc.smallSaving.limit"
(ngModelChange)="markAsDirty()"
/>
</td>
</tr>
</tbody>
</table>
</div>
<div class="spl-note">
<strong>Note:</strong>
Small Saving Account limits are strictly capped at Rs.1,00,000 maximum balance and Rs.10,000 monthly withdrawal
per RBI Master Circular. Minor accounts are restricted from RTGS and high-risk digital products (BEPG/BBPS) by
default.
</div>
</div>
</div>
@@ -0,0 +1,257 @@
/**
* 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: #d9e2ec;
$border-strong: #c7d2de;
$text: #24364b;
$muted: #64748b;
$header-bg: #f8fafc;
$highlight-bg: #eff6ff;
.spl-section {
border: 1px solid $border;
border-radius: 12px;
overflow: hidden;
background: #fff;
}
.spl-section__header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
background: $header-bg;
border-bottom: 1px solid $border;
}
.spl-section__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: #eff6ff;
color: #2563eb;
font-size: 14px;
flex: 0 0 auto;
}
.spl-section__title {
flex: 1 1 auto;
font-size: 16px;
font-weight: 600;
color: $text;
}
.spl-section__badge {
flex: 0 0 auto;
padding: 5px 10px;
border-radius: 999px;
background: #dbeafe;
color: #1d4ed8;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
white-space: nowrap;
}
.spl-section__body {
padding: 18px 20px 20px;
}
.spl-subsection {
border: 1px solid $border;
border-radius: 12px;
overflow: hidden;
background: #fff;
& + & {
margin-top: 16px;
}
}
.spl-subsection__header {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: $header-bg;
border-bottom: 1px solid $border;
color: $text;
font-size: 14px;
font-weight: 600;
}
.spl-subsection__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 8px;
background: #eff6ff;
color: #2563eb;
font-size: 13px;
flex: 0 0 auto;
}
.spl-subsection__body {
padding: 16px 16px 18px;
}
.spl-table-wrap {
overflow-x: auto;
}
.spl-table {
width: 100%;
min-width: 0;
table-layout: fixed;
border-collapse: collapse;
border: 1px solid $border;
col.spl-table__col--label {
width: 22%;
}
col.spl-table__col--value {
width: 26%;
}
th,
td {
border: 1px solid $border;
padding: 6px 8px;
vertical-align: middle;
}
th {
background: #f8fafc;
color: #64748b;
text-align: center;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
white-space: normal;
word-break: break-word;
}
td:first-child {
background: #f8fafc;
font-weight: 600;
color: $text;
white-space: nowrap;
}
td {
font-size: 13px;
overflow: hidden;
}
}
.spl-table__highlight td:first-child {
color: #1d4ed8;
}
.spl-input,
.spl-select {
width: 100%;
box-sizing: border-box;
font: inherit;
}
.spl-input {
height: 30px;
border: 1px solid $border-strong;
border-radius: 2px;
padding: 0 8px;
background: #fff;
}
.spl-input:disabled,
.spl-select:disabled {
background: #f8fafc;
color: $muted;
}
.spl-select {
height: 30px;
border: 1px solid $border-strong;
border-radius: 2px;
padding: 0 6px;
background: #fff;
}
.spl-input--stacked {
margin-top: 6px;
}
.spl-input--disabled {
background: #f8fafc;
color: $muted;
}
.spl-table__muted {
font-size: 11px;
color: #94a3b8;
letter-spacing: 0.03em;
}
.spl-note {
margin-top: 16px;
padding: 12px 14px;
border: 1px solid $border;
border-radius: 8px;
background: #f8fafc;
color: $muted;
font-size: 11px;
line-height: 1.6;
}
.spl-features {
margin-bottom: 16px;
}
.spl-features__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px 16px;
@media (width <= 900px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (width <= 640px) {
grid-template-columns: 1fr;
}
}
.spl-feature {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
color: $text;
}
.spl-feature__label {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 18px;
font-weight: 600;
color: #334155;
i {
color: #2563eb;
font-size: 12px;
width: 14px;
text-align: center;
}
}
@@ -0,0 +1,250 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
type BooleanFeatureKey =
| 'atmFacility'
| 'atmWithdrawalFacility'
| 'upiFacility'
| 'posTransactionsFacility'
| 'chequeBook'
| 'impsFacility'
| 'smsAlerts'
| 'bbpsFacility'
| 'insuranceCover'
| 'bepgFacility'
| 'neftFacility'
| 'rtgsFacility'
| 'interBankTotalCapFacility'
| 'withdrawalFacility'
| 'depositFacilityKyc'
| 'virtualDebitCard';
interface FacilityLimitRow {
limitType: string;
generalLimit: string | number | null;
minorAccountLimit: string | number | null;
smallSavingLimit: string | number | null;
}
@Component({
selector: 'mifosx-saving-product-limits-step',
templateUrl: './saving-product-limits-step.component.html',
styleUrls: ['./saving-product-limits-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
]
})
export class SavingProductLimitsStepComponent implements OnChanges {
@Input() savingProductsTemplate: any;
pristine = true;
yesNoOptions: Array<{
value: boolean;
label: string;
}> = [
{
value: true,
label: 'Yes'
},
{
value: false,
label: 'No'
}
];
accountFacilityConfig: Record<BooleanFeatureKey, boolean> = {
atmFacility: true,
atmWithdrawalFacility: true,
upiFacility: true,
posTransactionsFacility: true,
chequeBook: true,
impsFacility: true,
smsAlerts: true,
bbpsFacility: true,
insuranceCover: false,
bepgFacility: true,
neftFacility: true,
rtgsFacility: true,
interBankTotalCapFacility: true,
withdrawalFacility: true,
depositFacilityKyc: true,
virtualDebitCard: false
};
accountFacilityLimits: FacilityLimitRow[] = [
{
limitType: 'ATM Withdrawal',
generalLimit: 40000,
minorAccountLimit: 5000,
smallSavingLimit: 10000
},
{
limitType: 'POS Transactions',
generalLimit: 100000,
minorAccountLimit: 2000,
smallSavingLimit: 10000
},
{
limitType: 'UPI Limit',
generalLimit: 50000,
minorAccountLimit: 1000,
smallSavingLimit: 5000
},
{
limitType: 'IMPS Limit',
generalLimit: 50000,
minorAccountLimit: 100,
smallSavingLimit: 5000
},
{
limitType: 'BBPS',
generalLimit: 25000,
minorAccountLimit: 0,
smallSavingLimit: 2000
},
{
limitType: 'BEPG',
generalLimit: 25000,
minorAccountLimit: 0,
smallSavingLimit: 2000
},
{
limitType: 'NEFT Limit',
generalLimit: 1000000,
minorAccountLimit: 25000,
smallSavingLimit: 10000
},
{
limitType: 'RTGS Limit (Min 2L)',
generalLimit: 5000000,
minorAccountLimit: 'N/A',
smallSavingLimit: 'N/A'
},
{
limitType: 'Inter-Bank Total Cap',
generalLimit: 5000000,
minorAccountLimit: 25000,
smallSavingLimit: 10000
},
{
limitType: 'Withdrawal Limit',
generalLimit: 'UNLIMITED',
minorAccountLimit: 10000,
smallSavingLimit: 10000
}
];
depositLimitKyc = {
general: {
kycType: 'Full KYC',
limit: 'No Limit'
},
minor: {
kycType: 'Guardian KYC',
limit: 100000
},
smallSaving: {
kycType: 'Min KYC',
limit: 50000
}
};
ngOnChanges(changes: SimpleChanges) {
if (changes.savingProductsTemplate?.currentValue) {
const template = changes.savingProductsTemplate.currentValue;
this.setYesNoOptions(template);
this.populateFromTemplate(template);
}
}
markAsDirty() {
this.pristine = false;
}
private populateFromTemplate(template: any) {
const isEditing = !!template.id;
// Populate accountFacilityConfig from existing product
if (isEditing && template.accountFacilityConfig) {
this.accountFacilityConfig = { ...this.accountFacilityConfig, ...template.accountFacilityConfig };
}
// Populate accountFacilityLimits from existing product
if (isEditing && template.accountFacilityLimits && template.accountFacilityLimits.length > 0) {
this.accountFacilityLimits = template.accountFacilityLimits.map((limit: any) => ({
limitType: limit.limitType || '',
generalLimit: limit.generalLimit ?? null,
minorAccountLimit: limit.minorAccountLimit ?? null,
smallSavingLimit: limit.smallSavingLimit ?? null
}));
}
// Populate depositLimitKyc from existing product
if (isEditing && template.depositLimitKyc) {
this.depositLimitKyc = {
general: {
kycType: template.depositLimitKyc.general?.kycType || 'Full KYC',
limit: template.depositLimitKyc.general?.limit || 'No Limit'
},
minor: {
kycType: template.depositLimitKyc.minor?.kycType || 'Guardian KYC',
limit: template.depositLimitKyc.minor?.limit ?? 100000
},
smallSaving: {
kycType: template.depositLimitKyc.smallSaving?.kycType || 'Min KYC',
limit: template.depositLimitKyc.smallSaving?.limit ?? 50000
}
};
}
}
private setYesNoOptions(template: any) {
const options = Array.isArray(template?.yesNoOptions) ? template.yesNoOptions : [];
const mappedOptions = options
.map((option: any) => {
const rawValue = option?.name ?? option?.value ?? option;
const normalized = typeof rawValue === 'string' ? rawValue.trim().toUpperCase() : rawValue;
if (normalized === 'YES') {
return {
value: true,
label: 'Yes'
};
}
if (normalized === 'NO') {
return {
value: false,
label: 'No'
};
}
return null;
})
.filter((value: any) => !!value);
if (mappedOptions.length) {
this.yesNoOptions = mappedOptions;
}
}
get savingProductLimits() {
return {
accountFacilityConfig: { ...this.accountFacilityConfig },
accountFacilityLimits: this.accountFacilityLimits.map((row) => ({ ...row })),
depositLimitKyc: {
...this.depositLimitKyc
}
};
}
}
@@ -25,25 +25,107 @@
</div> </div>
} }
@if (
savingProduct.accountType ||
savingProduct.customerSegment ||
savingProduct.productCode ||
savingProduct.effectiveTo ||
savingProduct.introducerRequired !== undefined ||
savingProduct.shareLinkingEnabled !== undefined
) {
<h3 class="mat-h3 flex-100">Product Classification</h3>
<mat-divider class="flex-100"></mat-divider>
@if (savingProduct.accountType) {
<div class="flex-100 layout-row">
<span class="flex-40">Account Type:</span>
<span class="flex-60">{{ savingProduct.accountType }}</span>
</div>
}
@if (savingProduct.customerSegment) {
<div class="flex-100 layout-row">
<span class="flex-40">Customer Segment:</span>
<span class="flex-60">{{ savingProduct.customerSegment }}</span>
</div>
}
@if (savingProduct.productCode) {
<div class="flex-100 layout-row">
<span class="flex-40">Product Code:</span>
<span class="flex-60">{{ savingProduct.productCode }}</span>
</div>
}
@if (savingProduct.effectiveTo) {
<div class="flex-100 layout-row">
<span class="flex-40">Effective To:</span>
<span class="flex-60">{{ savingProduct.effectiveTo }}</span>
</div>
}
@if (savingProduct.endDate) {
<div class="flex-100 layout-row">
<span class="flex-40">End Date:</span>
<span class="flex-60">{{ savingProduct.endDate }}</span>
</div>
}
@if (savingProduct.introducerRequired !== undefined) {
<div class="flex-100 layout-row">
<span class="flex-40">Introducer Required:</span>
<span class="flex-60">{{
savingProduct.introducerRequired === true || savingProduct.introducerRequired === 'Yes' ? 'Yes' : 'No'
}}</span>
</div>
}
@if (savingProduct.shareLinkingEnabled !== undefined) {
<div class="flex-100 layout-row">
<span class="flex-40">Share Linking Enabled:</span>
<span class="flex-60">{{ savingProduct.shareLinkingEnabled | yesNo }}</span>
</div>
}
}
<h3 class="mat-h3 flex-100">{{ 'labels.inputs.Currency' | translate }}</h3> <h3 class="mat-h3 flex-100">{{ 'labels.inputs.Currency' | translate }}</h3>
<mat-divider class="flex-100"></mat-divider> <mat-divider class="flex-100"></mat-divider>
<div class="flex-100 layout-row"> <div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Currency' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Currency' | translate }}:</span>
<span class="flex-60">{{ <span class="flex-60">{{
savingProduct.currencyCode | find: savingProductsTemplate.currencyOptions : 'code' : 'name' savingProduct.currency?.name ||
savingProduct.currency?.displayLabel ||
(savingProduct.currencyCode | find: savingProductsTemplate?.currencyOptions : 'code' : 'name') ||
savingProduct.currencyCode ||
'-'
}}</span> }}</span>
</div> </div>
<div class="flex-100 layout-row"> <div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Decimal Places' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Decimal Places' | translate }}:</span>
<span class="flex-60">{{ savingProduct.digitsAfterDecimal ?? savingProduct.currency?.decimalPlaces }}</span> <span class="flex-60">{{ savingProduct.digitsAfterDecimal ?? savingProduct.currency?.decimalPlaces ?? 2 }}</span>
</div> </div>
@if (savingProduct.inMultiplesOf) { @if (savingProduct.currency?.inMultiplesOf !== undefined && savingProduct.currency?.inMultiplesOf !== null) {
<div class="flex-100 layout-row"> <div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Currency in multiples of' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Currency in multiples of' | translate }}:</span>
<span class="flex-60">{{ savingProduct.inMultiplesOf }}</span> <span class="flex-60">{{ savingProduct.currency.inMultiplesOf }}</span>
</div>
}
@if (savingProduct.dormancyAlert !== undefined) {
<div class="flex-100 layout-row">
<span class="flex-40">Dormancy Alert:</span>
<span class="flex-60">{{ savingProduct.dormancyAlert | yesNo }}</span>
</div>
}
@if (savingProduct.nominationFacility !== undefined) {
<div class="flex-100 layout-row">
<span class="flex-40">Nomination Facility:</span>
<span class="flex-60">{{ savingProduct.nominationFacility | yesNo }}</span>
</div> </div>
} }
@@ -59,7 +141,7 @@
<span class="flex-40">{{ 'labels.inputs.Interest Compounding Period' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Interest Compounding Period' | translate }}:</span>
<span class="flex-60">{{ <span class="flex-60">{{
savingProduct.interestCompoundingPeriodType savingProduct.interestCompoundingPeriodType
| find: savingProductsTemplate.interestCompoundingPeriodTypeOptions : 'id' : 'value' | find: savingProductsTemplate?.interestCompoundingPeriodTypeOptions : 'id' : 'value'
| translateKey: 'catalogs' | translateKey: 'catalogs'
}}</span> }}</span>
</div> </div>
@@ -68,16 +150,16 @@
<span class="flex-40">{{ 'labels.inputs.Interest Posting Period' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Interest Posting Period' | translate }}:</span>
<span class="flex-60">{{ <span class="flex-60">{{
savingProduct.interestPostingPeriodType savingProduct.interestPostingPeriodType
| find: savingProductsTemplate.interestPostingPeriodTypeOptions : 'id' : 'value' | find: savingProductsTemplate?.interestPostingPeriodTypeOptions : 'id' : 'value'
| translateKey: 'catalogs' | translateKey: 'catalogs'
}}</span> }}</span>
</div> </div>
<div class="flex-100 layout-row"> <div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Interest Calculated using' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Interest Calculation Type' | translate }}:</span>
<span class="flex-60">{{ <span class="flex-60">{{
savingProduct.interestCalculationType savingProduct.interestCalculationType
| find: savingProductsTemplate.interestCalculationTypeOptions : 'id' : 'value' | find: interestCalculationTypeOptionData : 'id' : 'value'
| translateKey: 'catalogs' | translateKey: 'catalogs'
}}</span> }}</span>
</div> </div>
@@ -86,11 +168,56 @@
<span class="flex-40">{{ 'labels.inputs.Days in Year' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Days in Year' | translate }}:</span>
<span class="flex-60">{{ <span class="flex-60">{{
savingProduct.interestCalculationDaysInYearType savingProduct.interestCalculationDaysInYearType
| find: savingProductsTemplate.interestCalculationDaysInYearTypeOptions : 'id' : 'value' | find: savingProductsTemplate?.interestCalculationDaysInYearTypeOptions : 'id' : 'value'
| translateKey: 'catalogs' | translateKey: 'catalogs'
}}</span> }}</span>
</div> </div>
@if (savingProduct.effectiveDate) {
<div class="flex-100 layout-row">
<span class="flex-40">Effective Date:</span>
<span class="flex-60">{{ savingProduct.effectiveDate }}</span>
</div>
}
<div class="flex-100 layout-row">
<span class="flex-40">Interest Calculate:</span>
<span class="flex-60">{{
savingProduct.interestCalculateSelector
| find: savingProductsTemplate?.interestCalculationTypeOptions : 'id' : 'value'
}}</span>
</div>
@if (savingProduct.averagePeriod) {
<div class="flex-100 layout-row">
<span class="flex-40">Average Period:</span>
<span class="flex-60">{{ savingProduct.averagePeriod }}</span>
</div>
}
<div class="flex-100 layout-row">
<span class="flex-40">Auto Interest Post:</span>
<span class="flex-60">{{ savingProduct.autoPost | yesNo }}</span>
</div>
@if (
savingProduct.frequencyFixDay !== undefined &&
savingProduct.frequencyFixDay !== null &&
savingProduct.frequencyFixDay !== ''
) {
<div class="flex-100 layout-row">
<span class="flex-40">Frequency (Fix Day):</span>
<span class="flex-60">{{ savingProduct.frequencyFixDay }}</span>
</div>
}
@if (savingProduct.lastInterestDate) {
<div class="flex-100 layout-row">
<span class="flex-40">Last Interest Date:</span>
<span class="flex-60">{{ savingProduct.lastInterestDate }}</span>
</div>
}
<h3 class="mat-h3 flex-100">{{ 'labels.inputs.Settings' | translate }}</h3> <h3 class="mat-h3 flex-100">{{ 'labels.inputs.Settings' | translate }}</h3>
<mat-divider class="flex-100"></mat-divider> <mat-divider class="flex-100"></mat-divider>
@@ -108,7 +235,8 @@
savingProduct.lockinPeriodFrequency + savingProduct.lockinPeriodFrequency +
' ' + ' ' +
(savingProduct.lockinPeriodFrequencyType (savingProduct.lockinPeriodFrequencyType
| find: savingProductsTemplate.lockinPeriodFrequencyTypeOptions : 'id' : 'value') | translateKey: 'catalogs' | find: savingProductsTemplate?.lockinPeriodFrequencyTypeOptions : 'id' : 'value')
| translateKey: 'catalogs'
}}</span> }}</span>
</div> </div>
} }
@@ -118,6 +246,28 @@
<span class="flex-60">{{ savingProduct.withdrawalFeeForTransfers | yesNo }}</span> <span class="flex-60">{{ savingProduct.withdrawalFeeForTransfers | yesNo }}</span>
</div> </div>
@if (savingProduct.balanceFrequency) {
<div class="flex-100 layout-row">
<span class="flex-40">Balance Frequency:</span>
<span class="flex-60">{{ savingProduct.balanceFrequency }}</span>
</div>
}
<div class="flex-100 layout-row">
<span class="flex-40">Enable Lock-in Period:</span>
<span class="flex-60">{{ savingProduct.enableLockinPeriod | yesNo }}</span>
</div>
@if (savingProduct.enableLockinPeriod && savingProduct.lockinPeriodFrequency) {
<div class="flex-100 layout-row">
<span class="flex-40">Lock-in Period:</span>
<span class="flex-60">
{{ savingProduct.lockinPeriodFrequency }}
{{ getLockinPeriodFrequencyTypeLabel(savingProduct.lockinPeriodFrequencyType) }}
</span>
</div>
}
@if (savingProduct.minBalanceForInterestCalculation) { @if (savingProduct.minBalanceForInterestCalculation) {
<div class="flex-100 layout-row"> <div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}:</span>
@@ -146,7 +296,7 @@
<div class="flex-100 layout-row"> <div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Tax Group' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Tax Group' | translate }}:</span>
<span class="flex-60">{{ <span class="flex-60">{{
savingProduct.taxGroupId | find: savingProductsTemplate.taxGroupOptions : 'id' : 'name' savingProduct.taxGroupId | find: savingProductsTemplate?.taxGroupOptions : 'id' : 'name'
}}</span> }}</span>
</div> </div>
} }
@@ -156,6 +306,11 @@
<span class="flex-60">{{ savingProduct.allowOverdraft | yesNo }}</span> <span class="flex-60">{{ savingProduct.allowOverdraft | yesNo }}</span>
</div> </div>
<div class="flex-100 layout-row">
<span class="flex-40">Account Closed with Zero Balance in Day Closing:</span>
<span class="flex-60">{{ savingProduct.accountClosedZeroBalanceInDayClosing | yesNo }}</span>
</div>
@if (savingProduct.minOverdraftForInterestCalculation) { @if (savingProduct.minOverdraftForInterestCalculation) {
<div class="flex-100 layout-row"> <div class="flex-100 layout-row">
<span class="flex-40" <span class="flex-40"
@@ -195,6 +350,77 @@
</div> </div>
} }
<h3 class="mat-h3 flex-100">Account Facilities</h3>
<mat-divider class="flex-100"></mat-divider>
@if (savingProduct.accountFacilityConfig) {
<div class="layout-row-wrap responsive-column flex-100">
@for (feature of accountFacilityFeatureItems; track feature.key) {
<div class="flex-100 layout-row">
<span class="flex-40">{{ feature.label }}:</span>
<span class="flex-60">{{ savingProduct.accountFacilityConfig[feature.key] | yesNo }}</span>
</div>
}
</div>
}
@if (savingProduct.accountFacilityLimits?.length) {
<div class="layout-row-wrap responsive-column flex-100">
<h4 class="mat-h4 flex-100">Account Facility Limits</h4>
<table class="flex-100 mat-elevation-z1" mat-table [dataSource]="savingProduct.accountFacilityLimits">
<ng-container matColumnDef="limitType">
<th mat-header-cell *matHeaderCellDef>Limit Type</th>
<td mat-cell *matCellDef="let row">{{ row.limitType }}</td>
</ng-container>
<ng-container matColumnDef="generalLimit">
<th mat-header-cell *matHeaderCellDef>General Limit</th>
<td mat-cell *matCellDef="let row">{{ row.generalLimit }}</td>
</ng-container>
<ng-container matColumnDef="minorAccountLimit">
<th mat-header-cell *matHeaderCellDef>Minor Account Limit</th>
<td mat-cell *matCellDef="let row">{{ row.minorAccountLimit }}</td>
</ng-container>
<ng-container matColumnDef="smallSavingLimit">
<th mat-header-cell *matHeaderCellDef>Small Saving Limit</th>
<td mat-cell *matCellDef="let row">{{ row.smallSavingLimit }}</td>
</ng-container>
<tr
mat-header-row
*matHeaderRowDef="['limitType', 'generalLimit', 'minorAccountLimit', 'smallSavingLimit']"
></tr>
<tr
mat-row
*matRowDef="let row; columns: ['limitType', 'generalLimit', 'minorAccountLimit', 'smallSavingLimit']"
></tr>
</table>
</div>
}
@if (savingProduct.depositLimitKyc) {
<div class="layout-row-wrap responsive-column flex-100">
<h4 class="mat-h4 flex-100">Deposit Limit (KYC)</h4>
<div class="flex-100 layout-row">
<span class="flex-40">General:</span>
<span class="flex-60"
>{{ savingProduct.depositLimitKyc.general.kycType }} - {{ savingProduct.depositLimitKyc.general.limit }}</span
>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Minor:</span>
<span class="flex-60"
>{{ savingProduct.depositLimitKyc.minor.kycType }} - {{ savingProduct.depositLimitKyc.minor.limit }}</span
>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Small Saving:</span>
<span class="flex-60">
{{ savingProduct.depositLimitKyc.smallSaving.kycType }} -
{{ savingProduct.depositLimitKyc.smallSaving.limit }}
</span>
</div>
</div>
}
@if (savingProduct.charges.length) { @if (savingProduct.charges.length) {
<div class="layout-row-wrap responsive-column flex-100"> <div class="layout-row-wrap responsive-column flex-100">
<h3 class="mat-h3 flex-100">{{ 'labels.inputs.Charges' | translate }}</h3> <h3 class="mat-h3 flex-100">{{ 'labels.inputs.Charges' | translate }}</h3>
@@ -233,9 +459,262 @@
<h3 class="mat-h3 flex-100">{{ 'labels.heading.Accounting' | translate }}</h3> <h3 class="mat-h3 flex-100">{{ 'labels.heading.Accounting' | translate }}</h3>
<mat-divider class="flex-100"></mat-divider> <mat-divider class="flex-100"></mat-divider>
@if (isCashOrAccrualAccounting()) {
<div class="spa-gl-summary flex-100">
<div class="spa-gl-summary__header">
<h4 class="mat-h4">GL Mapping</h4>
</div>
<mifosx-view-savings-accounting-details [accountingRule]="accountingRule" [accountingMappings]="accountingMappings"> <div class="spa-gl-section">
</mifosx-view-savings-accounting-details> <h5 class="mat-h5">Assets</h5>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Savings reference account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.savingsReferenceAccountId,
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Overdraft portfolio control</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.overdraftPortfolioControlId,
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Interest receivable account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.interestReceivableAccountId,
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Fees receivable account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.feesReceivableAccountId,
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Penalties receivable account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.penaltiesReceivableAccountId,
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
)
}}</span>
</div>
</div>
<div class="spa-gl-section">
<h5 class="mat-h5">Liabilities</h5>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Savings control account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.savingsControlAccountId,
savingProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Transfers in suspense account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.transfersInSuspenseAccountId,
savingProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Interest payable account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.interestPayableAccountId,
savingProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
)
}}</span>
</div>
@if (savingProduct.isDormancyTrackingActive) {
<div class="spa-gl-row">
<span class="spa-gl-row__label">Escheat liability account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.escheatLiabilityId,
savingProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
)
}}</span>
</div>
}
</div>
<div class="spa-gl-section">
<h5 class="mat-h5">Expenses</h5>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Interest on savings account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.interestOnSavingsAccountId,
savingProductsTemplate?.accountingMappingOptions.expenseAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Write off account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.writeOffAccountId,
savingProductsTemplate?.accountingMappingOptions.expenseAccountOptions
)
}}</span>
</div>
</div>
<div class="spa-gl-section">
<h5 class="mat-h5">Income</h5>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Income from fees</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.incomeFromFeeAccountId,
savingProductsTemplate?.accountingMappingOptions.incomeAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Income from penalties</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.incomeFromPenaltyAccountId,
savingProductsTemplate?.accountingMappingOptions.incomeAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Income from interest</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.incomeFromInterestId,
savingProductsTemplate?.accountingMappingOptions.incomeAccountOptions
)
}}</span>
</div>
</div>
</div>
}
@if (savingProduct.otherGlMappings) {
<div class="spa-other-gl-summary flex-100">
<div class="spa-gl-summary__header">
<h4 class="mat-h4">Other GL Mappings</h4>
</div>
@if (savingProduct.otherGlMappings.assets?.length) {
<table
class="spa-other-gl-table flex-100 mat-elevation-z1"
mat-table
[dataSource]="savingProduct.otherGlMappings.assets"
>
<ng-container matColumnDef="accountName">
<th mat-header-cell *matHeaderCellDef>Asset Account</th>
<td mat-cell *matCellDef="let row">{{ row.accountName }}</td>
</ng-container>
<ng-container matColumnDef="glCode">
<th mat-header-cell *matHeaderCellDef>GL Account</th>
<td mat-cell *matCellDef="let row">
{{
formatGlAccountByCode(row.glCode, savingProductsTemplate?.accountingMappingOptions.assetAccountOptions)
}}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="['accountName', 'glCode']"></tr>
<tr mat-row *matRowDef="let row; columns: ['accountName', 'glCode']"></tr>
</table>
}
@if (savingProduct.otherGlMappings.liabilities?.length) {
<table
class="spa-other-gl-table flex-100 mat-elevation-z1"
mat-table
[dataSource]="savingProduct.otherGlMappings.liabilities"
>
<ng-container matColumnDef="accountName">
<th mat-header-cell *matHeaderCellDef>Liability Account</th>
<td mat-cell *matCellDef="let row">{{ row.accountName }}</td>
</ng-container>
<ng-container matColumnDef="glCode">
<th mat-header-cell *matHeaderCellDef>GL Account</th>
<td mat-cell *matCellDef="let row">
{{
formatGlAccountByCode(
row.glCode,
savingProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
)
}}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="['accountName', 'glCode']"></tr>
<tr mat-row *matRowDef="let row; columns: ['accountName', 'glCode']"></tr>
</table>
}
@if (savingProduct.otherGlMappings.expenses?.length) {
<table
class="spa-other-gl-table flex-100 mat-elevation-z1"
mat-table
[dataSource]="savingProduct.otherGlMappings.expenses"
>
<ng-container matColumnDef="accountName">
<th mat-header-cell *matHeaderCellDef>Expense Account</th>
<td mat-cell *matCellDef="let row">{{ row.accountName }}</td>
</ng-container>
<ng-container matColumnDef="glCode">
<th mat-header-cell *matHeaderCellDef>GL Account</th>
<td mat-cell *matCellDef="let row">
{{
formatGlAccountByCode(
row.glCode,
savingProductsTemplate?.accountingMappingOptions.expenseAccountOptions
)
}}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="['accountName', 'glCode']"></tr>
<tr mat-row *matRowDef="let row; columns: ['accountName', 'glCode']"></tr>
</table>
}
@if (savingProduct.otherGlMappings.income?.length) {
<table
class="spa-other-gl-table flex-100 mat-elevation-z1"
mat-table
[dataSource]="savingProduct.otherGlMappings.income"
>
<ng-container matColumnDef="accountName">
<th mat-header-cell *matHeaderCellDef>Income Account</th>
<td mat-cell *matCellDef="let row">{{ row.accountName }}</td>
</ng-container>
<ng-container matColumnDef="glCode">
<th mat-header-cell *matHeaderCellDef>GL Account</th>
<td mat-cell *matCellDef="let row">
{{
formatGlAccountByCode(row.glCode, savingProductsTemplate?.accountingMappingOptions.incomeAccountOptions)
}}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="['accountName', 'glCode']"></tr>
<tr mat-row *matRowDef="let row; columns: ['accountName', 'glCode']"></tr>
</table>
}
</div>
}
@if ( @if (
savingProduct.advancedAccountingRules && savingProduct.advancedAccountingRules &&
@@ -265,7 +744,7 @@
<td mat-cell *matCellDef="let paymentFundSource"> <td mat-cell *matCellDef="let paymentFundSource">
{{ {{
paymentFundSource.fundSourceAccountId paymentFundSource.fundSourceAccountId
| find: savingProductsTemplate.accountingMappingOptions.assetAccountOptions : 'id' : 'name' | find: savingProductsTemplate?.accountingMappingOptions.assetAccountOptions : 'id' : 'name'
}} }}
</td> </td>
</ng-container> </ng-container>
@@ -289,7 +768,7 @@
<td mat-cell *matCellDef="let feesIncome"> <td mat-cell *matCellDef="let feesIncome">
{{ {{
feesIncome.incomeAccountId feesIncome.incomeAccountId
| find: savingProductsTemplate.accountingMappingOptions.incomeAccountOptions : 'id' : 'name' | find: savingProductsTemplate?.accountingMappingOptions.incomeAccountOptions : 'id' : 'name'
}} }}
</td> </td>
</ng-container> </ng-container>
@@ -317,7 +796,7 @@
<td mat-cell *matCellDef="let penaltyIncome"> <td mat-cell *matCellDef="let penaltyIncome">
{{ {{
penaltyIncome.incomeAccountId penaltyIncome.incomeAccountId
| find: savingProductsTemplate.accountingMappingOptions.incomeAccountOptions : 'id' : 'name' | find: savingProductsTemplate?.accountingMappingOptions.incomeAccountOptions : 'id' : 'name'
}} }}
</td> </td>
</ng-container> </ng-container>
@@ -329,4 +808,3 @@
</div> </div>
} }
</div> </div>
@@ -71,3 +71,95 @@ mat-divider {
.margin-t { .margin-t {
margin-top: 1em; margin-top: 1em;
} }
.spa-gl-summary,
.spa-other-gl-summary {
display: flex;
flex-direction: column;
gap: 0.9rem;
margin-top: 1.25rem;
}
.spa-gl-summary__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0 0.5rem;
}
.spa-gl-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem 1.15rem;
border: 1px solid #e3e7f2;
border-radius: 12px;
background: #fff;
}
.spa-gl-section h5 {
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #91a0bc;
}
.spa-gl-row {
display: grid;
grid-template-columns: minmax(220px, 40%) 1fr;
gap: 1rem;
align-items: baseline;
padding: 0.2rem 0;
}
.spa-gl-row__label {
color: #1f2f4a;
font-weight: 500;
}
.spa-gl-row__value {
color: #101828;
font-weight: 600;
word-break: break-word;
}
.spa-other-gl-table {
margin-top: 0.5rem;
margin-bottom: 0.75rem;
table-layout: fixed;
width: 100%;
}
.spa-other-gl-table:first-of-type {
margin-top: 0.25rem;
}
.spa-other-gl-summary .mat-elevation-z1 {
border-radius: 10px;
overflow: hidden;
}
.spa-other-gl-table th.mat-mdc-header-cell,
.spa-other-gl-table td.mat-mdc-cell {
padding-left: 1.25rem;
padding-right: 1.25rem;
}
.spa-other-gl-table th.mat-mdc-header-cell:first-child,
.spa-other-gl-table td.mat-mdc-cell:first-child {
width: 38%;
}
.spa-other-gl-table th.mat-mdc-header-cell:last-child,
.spa-other-gl-table td.mat-mdc-cell:last-child {
width: 62%;
word-break: break-word;
}
@media (width <= 768px) {
.spa-gl-row {
grid-template-columns: 1fr;
gap: 0.35rem;
}
}
@@ -22,7 +22,6 @@ import {
MatRowDef, MatRowDef,
MatRow MatRow
} from '@angular/material/table'; } from '@angular/material/table';
import { ViewSavingsAccountingDetailsComponent } from '../../../../shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { FindPipe } from '../../../../pipes/find.pipe'; import { FindPipe } from '../../../../pipes/find.pipe';
import { FormatNumberPipe } from '../../../../pipes/format-number.pipe'; import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
@@ -46,7 +45,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatHeaderRow, MatHeaderRow,
MatRowDef, MatRowDef,
MatRow, MatRow,
ViewSavingsAccountingDetailsComponent,
FaIconComponent, FaIconComponent,
FindPipe, FindPipe,
FormatNumberPipe, FormatNumberPipe,
@@ -79,6 +77,25 @@ export class SavingProductPreviewStepComponent implements OnInit, OnChanges {
accountingMappings: any = {}; accountingMappings: any = {};
accountingRule: OptionData; accountingRule: OptionData;
interestCalculationTypeOptionData: any[] = [];
accountFacilityFeatureItems = [
{ key: 'atmFacility', label: 'ATM Facility' },
{ key: 'atmWithdrawalFacility', label: 'ATM Withdrawal Facility' },
{ key: 'upiFacility', label: 'UPI Facility' },
{ key: 'posTransactionsFacility', label: 'POS Transactions Facility' },
{ key: 'chequeBook', label: 'Cheque Book' },
{ key: 'impsFacility', label: 'IMPS Facility' },
{ key: 'smsAlerts', label: 'SMS Alerts' },
{ key: 'bbpsFacility', label: 'BBPS Facility' },
{ key: 'insuranceCover', label: 'Insurance Cover' },
{ key: 'bepgFacility', label: 'BEPG Facility' },
{ key: 'neftFacility', label: 'NEFT Facility' },
{ key: 'rtgsFacility', label: 'RTGS Facility' },
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap Facility' },
{ key: 'withdrawalFacility', label: 'Withdrawal Facility' },
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' }
];
ngOnInit() { ngOnInit() {
this.setCurrentValues(); this.setCurrentValues();
@@ -89,13 +106,24 @@ export class SavingProductPreviewStepComponent implements OnInit, OnChanges {
} }
setCurrentValues(): void { setCurrentValues(): void {
if (!this.savingProduct || !this.savingProductsTemplate) {
return;
}
this.interestCalculationTypeOptionData =
this.savingProductsTemplate.interestCalculationTypeOption ||
this.savingProductsTemplate.interestCalculationTypeOptions ||
[];
this.accountingRule = this.accounting.getAccountingRuleFrom(this.savingProduct.accountingRule); this.accountingRule = this.accounting.getAccountingRuleFrom(this.savingProduct.accountingRule);
if (this.isCashOrAccrualAccounting()) { if (!this.isCashOrAccrualAccounting()) {
const assetAccountData = this.savingProductsTemplate.accountingMappingOptions.assetAccountOptions || []; return;
const incomeAccountData = this.savingProductsTemplate.accountingMappingOptions.incomeAccountOptions || []; }
const expenseAccountData = this.savingProductsTemplate.accountingMappingOptions.expenseAccountOptions || [];
const liabilityAccountData = this.savingProductsTemplate.accountingMappingOptions.liabilityAccountOptions || []; const assetAccountData = this.savingProductsTemplate?.accountingMappingOptions?.assetAccountOptions || [];
const incomeAccountData = this.savingProductsTemplate?.accountingMappingOptions?.incomeAccountOptions || [];
const expenseAccountData = this.savingProductsTemplate?.accountingMappingOptions?.expenseAccountOptions || [];
const liabilityAccountData = this.savingProductsTemplate?.accountingMappingOptions?.liabilityAccountOptions || [];
this.accountingMappings = { this.accountingMappings = {
savingsReferenceAccount: this.accounting.glAccountLookUp( savingsReferenceAccount: this.accounting.glAccountLookUp(
@@ -114,7 +142,6 @@ export class SavingProductPreviewStepComponent implements OnInit, OnChanges {
this.savingProduct.transfersInSuspenseAccountId, this.savingProduct.transfersInSuspenseAccountId,
liabilityAccountData liabilityAccountData
), ),
escheatLiability: this.accounting.glAccountLookUp(this.savingProduct.escheatLiabilityId, liabilityAccountData),
interestOnSavingsAccount: this.accounting.glAccountLookUp( interestOnSavingsAccount: this.accounting.glAccountLookUp(
this.savingProduct.interestOnSavingsAccountId, this.savingProduct.interestOnSavingsAccountId,
expenseAccountData expenseAccountData
@@ -132,19 +159,35 @@ export class SavingProductPreviewStepComponent implements OnInit, OnChanges {
}; };
if (this.isAccrualAccounting()) { if (this.isAccrualAccounting()) {
this.accountingMappings['feesReceivableAccount'] = this.accounting.glAccountLookUp( this.accountingMappings = {
...this.accountingMappings,
feeReceivableAccount: this.accounting.glAccountLookUp(
this.savingProduct.feesReceivableAccountId, this.savingProduct.feesReceivableAccountId,
assetAccountData assetAccountData
); ),
this.accountingMappings['penaltiesReceivableAccount'] = this.accounting.glAccountLookUp( penaltyReceivableAccount: this.accounting.glAccountLookUp(
this.savingProduct.penaltiesReceivableAccountId, this.savingProduct.penaltiesReceivableAccountId,
assetAccountData assetAccountData
); ),
this.accountingMappings['interestPayableAccount'] = this.accounting.glAccountLookUp( interestPayableAccount: this.accounting.glAccountLookUp(
this.savingProduct.interestPayableAccountId, this.savingProduct.interestPayableAccountId,
liabilityAccountData liabilityAccountData
); ),
interestReceivableAccount: this.accounting.glAccountLookUp(
this.savingProduct.interestReceivableAccountId,
assetAccountData
)
};
} }
if (this.savingProduct.isDormancyTrackingActive) {
this.accountingMappings = {
...this.accountingMappings,
escheatLiabilityAccount: this.accounting.glAccountLookUp(
this.savingProduct.escheatLiabilityId,
liabilityAccountData
)
};
} }
} }
@@ -155,4 +198,50 @@ export class SavingProductPreviewStepComponent implements OnInit, OnChanges {
isAccrualAccounting(): boolean { isAccrualAccounting(): boolean {
return this.accounting.isAccrualAccounting(this.accountingRule); return this.accounting.isAccrualAccounting(this.accountingRule);
} }
getBooleanLabel(value: any): string {
return value === true || value === 'Yes' ? 'Yes' : 'No';
}
getLockinPeriodFrequencyTypeLabel(value: any): string {
switch (value) {
case 'DAYS':
return 'Days';
case 'WEEKS':
return 'Weeks';
case 'MONTHS':
return 'Months';
case 'YEARS':
return 'Years';
default:
return value ?? '';
}
}
getOptionLabel(value: any, options: any[], key: string, property: string): string {
if (value === null || value === undefined || !Array.isArray(options)) {
return '-';
}
const match = options.find((option) => option?.[key] === value);
return match?.[property] || '-';
}
formatGlAccountById(glAccountId: number, options: any[]): string {
if (!glAccountId || !Array.isArray(options)) {
return '-';
}
const account = this.accounting.glAccountLookUp(glAccountId, options as any);
return account ? `(${account.glCode}) ${account.name}` : '-';
}
formatGlAccountByCode(glCode: string, options: any[]): string {
if (!glCode || !Array.isArray(options)) {
return '-';
}
const account = options.find((option) => option?.glCode === glCode);
return account ? `(${account.glCode}) ${account.name}` : glCode;
}
} }
@@ -1,4 +1,4 @@
<!-- <!--
Copyright since 2025 Mifos Initiative Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public This Source Code Form is subject to the terms of the Mozilla Public
@@ -7,326 +7,103 @@
--> -->
<form [formGroup]="savingProductSettingsForm"> <form [formGroup]="savingProductSettingsForm">
<!-- Opening Balance -->
<div class="sps-section"> <div class="sps-section">
<div class="sps-section__header"> <div class="sps-section__header">
<i class="fas fa-wallet"></i> <span class="sps-section__badge"><i class="fas fa-balance-scale"></i></span>
<h3>{{ 'labels.inputs.Opening Balance' | translate }}</h3> <span>Balance &amp; Minimum Balance</span>
</div> </div>
<div class="sps-grid"> <div class="sps-body">
<mat-form-field appearance="outline" class="sps-field"> <div class="sps-layout sps-layout--balance">
<mat-label>{{ 'labels.inputs.Minimum Opening Balance' | translate }}</mat-label> <div class="sps-row sps-row--three">
<input <mat-form-field appearance="outline" class="sps-field sps-field--compact">
type="number" <mat-label>Minimum Opening Balance</mat-label>
matInput <input type="number" matInput formControlName="minRequiredOpeningBalance" min="0" step="0.01" />
matTooltip="{{ 'tooltips.Sets the minimum deposit amount' | translate }}"
formControlName="minRequiredOpeningBalance"
min="0"
step="0.01"
/>
@if (savingProductSettingsForm.get('minRequiredOpeningBalance').hasError('min')) {
<mat-error>
{{ 'labels.inputs.Minimum Opening Balance' | translate }} {{ 'labels.commons.must be' | translate }}
{{ 'labels.commons.a positive number' | translate }}
</mat-error>
}
</mat-form-field> </mat-form-field>
</div>
</div>
<!-- Lock-in Period --> <div class="sps-toggle sps-toggle--inline">
<div class="sps-section"> <mat-checkbox labelPosition="before" formControlName="enforceMinRequiredBalance">
<div class="sps-section__header"> <span>Enforce Minimum Balance</span>
<i class="fas fa-lock"></i>
<h3>{{ 'labels.inputs.Lock-in Period' | translate }}</h3>
</div>
<div class="sps-grid">
<div class="sps-field--full sps-checkbox-row">
<mat-checkbox
labelPosition="before"
matTooltip="{{ 'tooltips.Used to indicate the length of time' | translate }}"
formControlName="enableLockinPeriod"
>
{{ 'labels.inputs.Enable Lock-in Period' | translate }}
</mat-checkbox> </mat-checkbox>
</div> </div>
@if (savingProductSettingsForm.value.enableLockinPeriod) { <mat-form-field appearance="outline" class="sps-field sps-field--compact">
<mat-form-field appearance="outline" class="sps-field"> <mat-label>Minimum Balance (₹)</mat-label>
<mat-label>{{ 'labels.inputs.Frequency' | translate }}</mat-label> <input type="number" matInput formControlName="minRequiredBalance" min="0" step="0.01" />
<input <mat-hint>Penal charge applies on breach</mat-hint>
type="number" </mat-form-field>
matInput </div>
matTooltip="{{ 'tooltips.The number at which lock in period occurs' | translate }}"
formControlName="lockinPeriodFrequency" <div class="sps-row sps-row--two">
min="1" <mat-form-field appearance="outline" class="sps-field sps-field--compact">
step="1" <mat-label>Balance Frequency</mat-label>
required <mat-select formControlName="balanceFrequency">
/> @for (balanceFrequency of periodFrequencyData; track balanceFrequency.id) {
@if (savingProductSettingsForm.get('lockinPeriodFrequency')?.hasError('required')) { <mat-option [value]="balanceFrequency.value">{{ balanceFrequency.value }}</mat-option>
<mat-error>
{{ 'labels.inputs.Frequency' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
@if (savingProductSettingsForm.get('lockinPeriodFrequency')?.hasError('min')) {
<mat-error>{{ 'tooltips.Frequency must be greater than zero' | translate }}</mat-error>
}
@if (savingProductSettingsForm.get('lockinPeriodFrequency')?.hasError('pattern')) {
<mat-error>{{ 'tooltips.Frequency must be a positive integer' | translate }}</mat-error>
} }
</mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="sps-field"> <div class="sps-toggle sps-toggle--inline sps-toggle--right">
<mat-label>{{ 'labels.inputs.Type' | translate }}</mat-label> <mat-checkbox labelPosition="before" formControlName="withdrawalFeeForTransfers">
<mat-select formControlName="lockinPeriodFrequencyType" required> <span>Apply Withdrawal Fee for Transfers</span>
</mat-checkbox>
</div>
</div>
</div>
</div>
</div>
<div class="sps-section">
<div class="sps-section__header">
<span class="sps-section__badge"><i class="fas fa-lock"></i></span>
<span>Lock-in Period</span>
</div>
<div class="sps-body">
<div class="sps-lockin-row">
<span>Enable Lock-in Period</span>
<mat-checkbox formControlName="enableLockinPeriod"></mat-checkbox>
</div>
@if (savingProductSettingsForm.value.enableLockinPeriod) {
<div class="sps-lockin-fields">
<mat-form-field appearance="outline" class="sps-field sps-field--compact">
<mat-label>Frequency*</mat-label>
<input type="number" matInput formControlName="lockinPeriodFrequency" min="1" step="1" />
</mat-form-field>
<mat-form-field appearance="outline" class="sps-field sps-field--compact">
<mat-label>Type*</mat-label>
<mat-select formControlName="lockinPeriodFrequencyType">
@for (lockinPeriodFrequencyType of lockinPeriodFrequencyTypeData; track lockinPeriodFrequencyType) { @for (lockinPeriodFrequencyType of lockinPeriodFrequencyTypeData; track lockinPeriodFrequencyType) {
<mat-option [value]="lockinPeriodFrequencyType.id"> <mat-option [value]="lockinPeriodFrequencyType.id">
{{ lockinPeriodFrequencyType.value | translateKey: 'catalogs' }} {{ lockinPeriodFrequencyType.value | translateKey: 'catalogs' }}
</mat-option> </mat-option>
} }
</mat-select> </mat-select>
@if (savingProductSettingsForm.get('lockinPeriodFrequencyType')?.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Type' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field> </mat-form-field>
</div>
} }
</div> </div>
</div> </div>
<!-- Balance & Fees -->
<div class="sps-section"> <div class="sps-section">
<div class="sps-section__header"> <div class="sps-section__header">
<i class="fas fa-balance-scale"></i> <span class="sps-section__badge"><i class="fas fa-cog"></i></span>
<h3>{{ 'labels.inputs.Balance' | translate }} &amp; {{ 'labels.inputs.Fees' | translate }}</h3> <span>Additional Rules</span>
</div> </div>
<div class="sps-grid"> <div class="sps-body">
<div class="sps-field sps-checkbox-row"> <div class="sps-layout sps-layout--additional">
<mat-checkbox <div class="sps-toggle sps-toggle--inline">
labelPosition="before" <mat-checkbox formControlName="accountClosedZeroBalanceInDayClosing">
matTooltip="{{ 'tooltips.Indicates whether the withdrawal fee' | translate }}" <span>Account Closed with Zero balance in Day Closing</span>
formControlName="withdrawalFeeForTransfers"
>
{{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }}
</mat-checkbox> </mat-checkbox>
</div> </div>
<mat-form-field appearance="outline" class="sps-field"> <mat-form-field appearance="outline" class="sps-field sps-field--compact">
<mat-label>{{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}</mat-label> <mat-label>Balance Required for Interest Calculation</mat-label>
<input <input type="number" matInput formControlName="minBalanceForInterestCalculation" min="0" step="0.01" />
type="number"
matInput
matTooltip="{{ 'tooltips.Sets the balance required for interest calculation' | translate }}"
formControlName="minBalanceForInterestCalculation"
min="0"
step="0.01"
/>
@if (savingProductSettingsForm.get('minBalanceForInterestCalculation').hasError('min')) {
<mat-error>
{{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}
{{ 'labels.commons.must be' | translate }}
{{ 'labels.commons.a positive number' | translate }}
</mat-error>
}
</mat-form-field>
<div class="sps-field sps-checkbox-row">
<mat-checkbox
labelPosition="before"
matTooltip="{{ 'tooltips.Indicates whether to enforce a minimum balance' | translate }}"
formControlName="enforceMinRequiredBalance"
>
{{ 'labels.inputs.Enforce Minimum Balance' | translate }}
</mat-checkbox>
</div>
<mat-form-field appearance="outline" class="sps-field">
<mat-label>{{ 'labels.inputs.Minimum Balance' | translate }}</mat-label>
<input
type="number"
matInput
matTooltip="{{ 'tooltips.Sets the minimum balance allowed for a saving account' | translate }}"
formControlName="minRequiredBalance"
min="0"
step="0.01"
/>
@if (savingProductSettingsForm.get('minRequiredBalance').hasError('min')) {
<mat-error>
{{ 'labels.inputs.Minimum Balance' | translate }} {{ 'labels.commons.must be' | translate }}
{{ 'labels.commons.a positive number' | translate }}
</mat-error>
}
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
<!-- Tax -->
<div class="sps-section">
<div class="sps-section__header">
<i class="fas fa-file-invoice-dollar"></i>
<h3>{{ 'labels.inputs.Tax' | translate }}</h3>
</div>
<div class="sps-grid">
<div class="sps-field--full sps-checkbox-row">
<mat-checkbox
labelPosition="before"
matTooltip="{{ 'tooltips.An boolean flag to attach' | translate }}"
formControlName="withHoldTax"
>
{{ 'labels.inputs.Is Withhold Tax Applicable' | translate }}
</mat-checkbox>
</div>
@if (savingProductSettingsForm.value.withHoldTax) {
<mat-form-field appearance="outline" class="sps-field">
<mat-label>{{ 'labels.inputs.Tax Group' | translate }}</mat-label>
<mat-select formControlName="taxGroupId" required>
@for (taxGroup of taxGroupData; track taxGroup) {
<mat-option [value]="taxGroup.id">
{{ taxGroup.name }}
</mat-option>
}
</mat-select>
<mat-error>
{{ 'labels.inputs.Tax Group' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
}
</div>
</div>
<!-- Overdraft -->
<div class="sps-section">
<div class="sps-section__header">
<i class="fas fa-exclamation-triangle"></i>
<h3>{{ 'labels.inputs.Overdraft' | translate }}</h3>
</div>
<div class="sps-grid">
<div class="sps-field--full sps-checkbox-row">
<mat-checkbox
labelPosition="before"
matTooltip="{{ 'tooltips.Indicates whether saving accounts based on this saving product' | translate }}"
formControlName="allowOverdraft"
>
{{ 'labels.inputs.Is Overdraft Allowed' | translate }}
</mat-checkbox>
</div>
@if (savingProductSettingsForm.value.allowOverdraft) {
<mat-form-field appearance="outline" class="sps-field">
<mat-label>{{ 'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate }}</mat-label>
<input
type="number"
matInput
matTooltip="{{ 'tooltips.Sets the overdraft required for interest calculation' | translate }}"
formControlName="minOverdraftForInterestCalculation"
min="0"
step="0.01"
/>
</mat-form-field>
<mat-form-field appearance="outline" class="sps-field">
<mat-label>{{ 'labels.inputs.Nominal Annual Interest for Overdraft' | translate }}</mat-label>
<input
type="number"
matInput
matTooltip="{{ 'tooltips.Default interest rate on overdraft' | translate }}"
formControlName="nominalAnnualInterestRateOverdraft"
min="0"
step="0.01"
/>
</mat-form-field>
<mat-form-field appearance="outline" class="sps-field">
<mat-label>{{ 'labels.inputs.Maximum Overdraft Amount Limit' | translate }}</mat-label>
<input
type="number"
matInput
matTooltip="{{ 'tooltips.Sets the maximum allowed overdraft amount' | translate }}"
formControlName="overdraftLimit"
min="0"
step="0.01"
/>
</mat-form-field>
}
</div>
</div>
<!-- Dormancy Tracking -->
<div class="sps-section">
<div class="sps-section__header">
<i class="fas fa-bed"></i>
<h3>{{ 'labels.inputs.Dormancy Tracking' | translate }}</h3>
</div>
<div class="sps-grid">
<div class="sps-field--full sps-checkbox-row">
<mat-checkbox
labelPosition="before"
matTooltip="{{ 'tooltips.Enables definition and tracking inactive Savings Accounts' | translate }}"
formControlName="isDormancyTrackingActive"
>
{{ 'labels.inputs.Enable Dormancy Tracking' | translate }}
</mat-checkbox>
</div>
@if (savingProductSettingsForm.value.isDormancyTrackingActive) {
<mat-form-field appearance="outline" class="sps-field">
<mat-label>{{ 'labels.inputs.Number of Days to Inactive sub-status' | translate }}</mat-label>
<input
type="number"
matInput
matTooltip="{{ 'tooltips.Inactive period to mark an account as Inactive' | translate }}"
formControlName="daysToInactive"
min="0"
step="1"
required
/>
<mat-error>
{{ 'labels.inputs.Number of Days to Inactive sub-status' | translate }}
{{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="sps-field">
<mat-label>{{ 'labels.inputs.Number of Days to Dormant sub-status' | translate }}</mat-label>
<input
type="number"
matInput
matTooltip="{{ 'tooltips.Inactive period to mark an account as Dormant' | translate }}"
formControlName="daysToDormancy"
min="0"
step="1"
required
/>
<mat-error>
{{ 'labels.inputs.Number of Days to Dormant sub-status' | translate }}
{{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="sps-field">
<mat-label>{{ 'labels.inputs.Number of Days to Escheat' | translate }}</mat-label>
<input
type="number"
matInput
matTooltip="{{ 'tooltips.Inactive period to mark an account as Escheat' | translate }}"
formControlName="daysToEscheat"
min="0"
step="1"
required
/>
<mat-error>
{{ 'labels.inputs.Number of Days to Escheat' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
}
</div>
</div> </div>
</form> </form>
@@ -44,15 +44,20 @@ $gray-900: #202124;
} }
} }
.sps-grid { .sps-section__badge {
display: grid; display: inline-flex;
grid-template-columns: repeat(2, 1fr); align-items: center;
gap: 4px 16px; justify-content: center;
padding: 16px 24px; width: 24px;
height: 24px;
border-radius: 6px;
background: #eff6ff;
font-size: 14px;
}
@media (max-width: 600px) { .sps-body {
grid-template-columns: 1fr; background: #fff;
} padding: 18px 24px 24px;
} }
.sps-field { .sps-field {
@@ -61,10 +66,112 @@ $gray-900: #202124;
&--full { &--full {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
&--compact {
width: 100%;
}
} }
.sps-checkbox-row { .sps-layout {
display: grid;
gap: 16px;
}
.sps-layout--balance {
gap: 22px;
}
.sps-layout--lockin {
grid-template-columns: repeat(2, minmax(0, 1fr));
@media (width <= 640px) {
grid-template-columns: 1fr;
}
}
.sps-layout--additional {
grid-template-columns: repeat(2, minmax(0, 1fr));
@media (width <= 1100px) {
grid-template-columns: 1fr;
}
}
.sps-row {
display: grid;
gap: 18px;
align-items: start;
}
.sps-row--three {
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
.sps-field--compact:first-child {
min-width: 0;
}
.sps-toggle--inline {
align-self: center;
min-height: 56px;
}
}
.sps-row--two {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
.sps-field--compact {
min-width: 0;
}
}
.sps-toggle {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 4px 0; min-height: 56px;
mat-checkbox {
font-size: 14px;
color: $gray-900;
}
&--full {
min-height: auto;
margin-bottom: 8px;
}
&--inline {
padding-top: 0;
}
&--right {
justify-content: flex-start;
}
}
.sps-lockin-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid $gray-200;
font-size: 14px;
color: $gray-900;
}
.sps-lockin-row mat-checkbox {
margin-right: 2px;
}
.sps-lockin-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
@media (width <= 640px) {
grid-template-columns: 1fr;
}
} }
@@ -1,4 +1,4 @@
/** /**
* Copyright since 2025 Mifos Initiative * Copyright since 2025 Mifos Initiative
* *
* This Source Code Form is subject to the terms of the Mozilla Public * This Source Code Form is subject to the terms of the Mozilla Public
@@ -7,16 +7,8 @@
*/ */
import { Component, OnInit, Input, inject } from '@angular/core'; import { Component, OnInit, Input, inject } from '@angular/core';
import { import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
UntypedFormGroup,
UntypedFormBuilder,
UntypedFormControl,
Validators,
ReactiveFormsModule
} from '@angular/forms';
import { MatTooltip } from '@angular/material/tooltip';
import { MatCheckbox } from '@angular/material/checkbox'; import { MatCheckbox } from '@angular/material/checkbox';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({ @Component({
@@ -25,9 +17,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
styleUrls: ['./saving-product-settings-step.component.scss'], styleUrls: ['./saving-product-settings-step.component.scss'],
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
MatTooltip, MatCheckbox
MatCheckbox,
FaIconComponent
] ]
}) })
export class SavingProductSettingsStepComponent implements OnInit { export class SavingProductSettingsStepComponent implements OnInit {
@@ -37,8 +27,26 @@ export class SavingProductSettingsStepComponent implements OnInit {
savingProductSettingsForm: UntypedFormGroup; savingProductSettingsForm: UntypedFormGroup;
lockinPeriodFrequencyTypeData: any; periodFrequencyData: any[] = [];
taxGroupData: any;
lockinPeriodFrequencyTypeData = [
{
id: 'DAYS',
value: 'value.days'
},
{
id: 'WEEKS',
value: 'value.weeks'
},
{
id: 'MONTHS',
value: 'value.months'
},
{
id: 'YEARS',
value: 'value.years'
}
];
constructor() { constructor() {
this.createSavingProductSettingsForm(); this.createSavingProductSettingsForm();
@@ -46,32 +54,34 @@ export class SavingProductSettingsStepComponent implements OnInit {
} }
ngOnInit() { ngOnInit() {
this.lockinPeriodFrequencyTypeData = this.savingProductsTemplate.lockinPeriodFrequencyTypeOptions; this.periodFrequencyData = this.savingProductsTemplate.periodFrequencyOptions || [];
this.taxGroupData = this.savingProductsTemplate.taxGroupOptions; const defaultPeriod = this.periodFrequencyData[0]?.value || 'Monthly';
const isEditing = !!this.savingProductsTemplate.id;
const hasLockinPeriod =
this.savingProductsTemplate.lockinPeriodFrequency && this.savingProductsTemplate.lockinPeriodFrequency > 0;
this.savingProductSettingsForm.patchValue({ this.savingProductSettingsForm.patchValue({
minRequiredOpeningBalance: this.savingProductsTemplate.minRequiredOpeningBalance, minRequiredOpeningBalance: this.savingProductsTemplate.minRequiredOpeningBalance,
enableLockinPeriod: hasLockinPeriod,
withdrawalFeeForTransfers: this.savingProductsTemplate.withdrawalFeeForTransfers, withdrawalFeeForTransfers: this.savingProductsTemplate.withdrawalFeeForTransfers,
minBalanceForInterestCalculation: this.savingProductsTemplate.minBalanceForInterestCalculation, minBalanceForInterestCalculation: this.savingProductsTemplate.minBalanceForInterestCalculation,
enforceMinRequiredBalance: this.savingProductsTemplate.enforceMinRequiredBalance, enforceMinRequiredBalance: this.savingProductsTemplate.enforceMinRequiredBalance,
minRequiredBalance: this.savingProductsTemplate.minRequiredBalance, minRequiredBalance: this.savingProductsTemplate.minRequiredBalance,
allowOverdraft: this.savingProductsTemplate.allowOverdraft, balanceFrequency: isEditing ? this.savingProductsTemplate.balanceFrequency || defaultPeriod : defaultPeriod,
minOverdraftForInterestCalculation: this.savingProductsTemplate.minOverdraftForInterestCalculation, enableLockinPeriod: isEditing
nominalAnnualInterestRateOverdraft: this.savingProductsTemplate.nominalAnnualInterestRateOverdraft, ? this.savingProductsTemplate.lockinPeriodFrequency && this.savingProductsTemplate.lockinPeriodFrequency > 0
overdraftLimit: this.savingProductsTemplate.overdraftLimit, : this.savingProductsTemplate.lockinPeriodFrequency && this.savingProductsTemplate.lockinPeriodFrequency > 0,
withHoldTax: this.savingProductsTemplate.withHoldTax, accountClosedZeroBalanceInDayClosing: isEditing
taxGroupId: this.savingProductsTemplate.taxGroup && this.savingProductsTemplate.taxGroup.id, ? (this.savingProductsTemplate.accountClosedZeroBalanceInDayClosing ?? false)
isDormancyTrackingActive: this.savingProductsTemplate.isDormancyTrackingActive, : false,
daysToInactive: this.savingProductsTemplate.daysToInactive, isDormancyTrackingActive: isEditing ? (this.savingProductsTemplate.isDormancyTrackingActive ?? true) : true,
daysToDormancy: this.savingProductsTemplate.daysToDormancy, daysToInactive: isEditing ? this.savingProductsTemplate.daysToInactive || 30 : 30,
daysToEscheat: this.savingProductsTemplate.daysToEscheat daysToDormancy: isEditing ? this.savingProductsTemplate.daysToDormancy || 60 : 60,
daysToEscheat: isEditing ? this.savingProductsTemplate.daysToEscheat || 90 : 90
}); });
if (hasLockinPeriod) { if (
isEditing &&
this.savingProductsTemplate.lockinPeriodFrequency &&
this.savingProductsTemplate.lockinPeriodFrequency > 0
) {
this.savingProductSettingsForm.patchValue({ this.savingProductSettingsForm.patchValue({
lockinPeriodFrequency: this.savingProductsTemplate.lockinPeriodFrequency, lockinPeriodFrequency: this.savingProductsTemplate.lockinPeriodFrequency,
lockinPeriodFrequencyType: lockinPeriodFrequencyType:
@@ -87,7 +97,6 @@ export class SavingProductSettingsStepComponent implements OnInit {
'', '',
[Validators.min(0)] [Validators.min(0)]
], ],
enableLockinPeriod: [false],
withdrawalFeeForTransfers: [false], withdrawalFeeForTransfers: [false],
minBalanceForInterestCalculation: [ minBalanceForInterestCalculation: [
'', '',
@@ -98,95 +107,42 @@ export class SavingProductSettingsStepComponent implements OnInit {
'', '',
[Validators.min(0)] [Validators.min(0)]
], ],
allowOverdraft: [false], balanceFrequency: ['Monthly'],
withHoldTax: [false], enableLockinPeriod: [false],
isDormancyTrackingActive: [false] lockinPeriodFrequency: [''],
lockinPeriodFrequencyType: [''],
accountClosedZeroBalanceInDayClosing: [false],
// Dormancy tracking fields (required when isDormancyTrackingActive is true)
isDormancyTrackingActive: [true],
daysToInactive: [30],
daysToDormancy: [60],
daysToEscheat: [90]
}); });
} }
setConditionalControls() { setConditionalControls() {
this.savingProductSettingsForm.get('enableLockinPeriod').valueChanges.subscribe((enableLockinPeriod: any) => { this.savingProductSettingsForm.get('enableLockinPeriod').valueChanges.subscribe((enableLockinPeriod: any) => {
if (enableLockinPeriod) { if (enableLockinPeriod) {
this.savingProductSettingsForm.addControl( this.savingProductSettingsForm.get('lockinPeriodFrequency').setValidators([
'lockinPeriodFrequency',
new UntypedFormControl('', [
Validators.required, Validators.required,
Validators.min(1), Validators.min(1),
Validators.pattern('^[1-9]\\d*$') Validators.pattern('^[1-9]\\d*$')
]) ]);
); this.savingProductSettingsForm.get('lockinPeriodFrequencyType').setValidators([
this.savingProductSettingsForm.addControl( Validators.required
'lockinPeriodFrequencyType', ]);
new UntypedFormControl('', Validators.required)
);
} else { } else {
this.savingProductSettingsForm.removeControl('lockinPeriodFrequency'); this.savingProductSettingsForm.get('lockinPeriodFrequency').setValue('');
this.savingProductSettingsForm.removeControl('lockinPeriodFrequencyType'); this.savingProductSettingsForm.get('lockinPeriodFrequencyType').setValue('');
} this.savingProductSettingsForm.get('lockinPeriodFrequency').clearValidators();
}); this.savingProductSettingsForm.get('lockinPeriodFrequencyType').clearValidators();
this.savingProductSettingsForm.get('allowOverdraft').valueChanges.subscribe((allowOverdraft: any) => {
if (allowOverdraft) {
this.savingProductSettingsForm.addControl(
'minOverdraftForInterestCalculation',
new UntypedFormControl('', [Validators.min(0)])
);
this.savingProductSettingsForm.addControl(
'nominalAnnualInterestRateOverdraft',
new UntypedFormControl('', [Validators.min(0)])
);
this.savingProductSettingsForm.addControl('overdraftLimit', new UntypedFormControl('', [Validators.min(0)]));
} else {
this.savingProductSettingsForm.removeControl('minOverdraftForInterestCalculation');
this.savingProductSettingsForm.removeControl('nominalAnnualInterestRateOverdraft');
this.savingProductSettingsForm.removeControl('overdraftLimit');
}
});
this.savingProductSettingsForm.get('withHoldTax').valueChanges.subscribe((withHoldTax: any) => {
if (withHoldTax) {
this.savingProductSettingsForm.addControl('taxGroupId', new UntypedFormControl('', Validators.required));
} else {
this.savingProductSettingsForm.removeControl('taxGroupId');
}
});
this.savingProductSettingsForm
.get('isDormancyTrackingActive')
.valueChanges.subscribe((isDormancyTrackingActive: any) => {
if (isDormancyTrackingActive) {
this.savingProductSettingsForm.addControl(
'daysToInactive',
new UntypedFormControl('', [
Validators.required,
Validators.min(0)
])
);
this.savingProductSettingsForm.addControl(
'daysToDormancy',
new UntypedFormControl('', [
Validators.required,
Validators.min(0)
])
);
this.savingProductSettingsForm.addControl(
'daysToEscheat',
new UntypedFormControl('', [
Validators.required,
Validators.min(0)
])
);
} else {
this.savingProductSettingsForm.removeControl('daysToInactive');
this.savingProductSettingsForm.removeControl('daysToDormancy');
this.savingProductSettingsForm.removeControl('daysToEscheat');
} }
this.savingProductSettingsForm.get('lockinPeriodFrequency').updateValueAndValidity();
this.savingProductSettingsForm.get('lockinPeriodFrequencyType').updateValueAndValidity();
}); });
} }
get savingProductSettings() { get savingProductSettings() {
const formValue = { ...this.savingProductSettingsForm.value }; return { ...this.savingProductSettingsForm.value };
delete formValue.enableLockinPeriod;
return formValue;
} }
} }
@@ -9,10 +9,19 @@
<form [formGroup]="savingProductTermsForm"> <form [formGroup]="savingProductTermsForm">
<div class="spt-section"> <div class="spt-section">
<div class="spt-section__header"> <div class="spt-section__header">
<i class="fas fa-percentage"></i> <span class="spt-section__badge"><i class="fas fa-chart-line"></i></span>
<h3>{{ 'labels.inputs.Interest Rate' | translate }}</h3> <span>Interest Rate &amp; Posting Terms</span>
</div> </div>
<div class="spt-grid"> <div class="spt-body">
<div class="spt-banner">
<span class="spt-banner__icon">ℹ️</span>
<span>
Savings Account base rate:
<b>{{ savingProductTermsForm.get('nominalAnnualInterestRate').value }}%</b>
</span>
</div>
<div class="spt-grid spt-grid--three">
<mat-form-field appearance="outline" class="spt-field"> <mat-form-field appearance="outline" class="spt-field">
<mat-label>{{ 'labels.inputs.Nominal Annual Interest' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Nominal Annual Interest' | translate }}</mat-label>
<input <input
@@ -31,38 +40,57 @@
</mat-error> </mat-error>
} }
@if (savingProductTermsForm.get('nominalAnnualInterestRate').hasError('min')) { @if (savingProductTermsForm.get('nominalAnnualInterestRate').hasError('min')) {
<mat-error> <mat-error> {{ 'labels.inputs.Nominal Annual Interest' | translate }} must be zero or greater </mat-error>
{{ 'labels.inputs.Nominal Annual Interest' | translate }} must be zero or greater
</mat-error>
} }
</mat-form-field> </mat-form-field>
</div>
</div>
<div class="spt-section">
<div class="spt-section__header">
<i class="fas fa-sliders-h"></i>
<h3>{{ 'labels.inputs.Interest Compounding' | translate }}</h3>
</div>
<div class="spt-grid">
<mat-form-field appearance="outline" class="spt-field"> <mat-form-field appearance="outline" class="spt-field">
<mat-label>{{ 'labels.inputs.Interest Compounding Period' | translate }}</mat-label> <mat-label>Effective Date</mat-label>
<input matInput [matDatepicker]="effectiveDatePicker" formControlName="effectiveDate" />
<mat-datepicker-toggle matSuffix [for]="effectiveDatePicker"></mat-datepicker-toggle>
<mat-datepicker #effectiveDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="spt-field">
<mat-label>{{ 'labels.inputs.Interest Calculation Type' | translate }}</mat-label>
<mat-select <mat-select
formControlName="interestCompoundingPeriodType" formControlName="interestCalculationType"
matTooltip="{{ 'tooltips.The period at which interest rate' | translate }}" matTooltip="{{ 'tooltips.The method used to calculate interest' | translate }}"
required required
> >
@for (interestCompoundingPeriodType of interestCompoundingPeriodTypeData; track interestCompoundingPeriodType) { @for (interestCalculationType of interestCalculationTypeOptionData; track interestCalculationType.id) {
<mat-option [value]="interestCompoundingPeriodType.id"> <mat-option [value]="interestCalculationType.id">
{{ interestCompoundingPeriodType.value | translateKey: 'catalogs' }} {{ interestCalculationType.value | translateKey: 'catalogs' }}
</mat-option> </mat-option>
} }
</mat-select> </mat-select>
<mat-error> <mat-error>
{{ 'labels.inputs.Interest Compounding Period' | translate }} {{ 'labels.commons.is' | translate }} {{ 'labels.inputs.Interest Calculation Type' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong> <strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</div>
<div class="spt-grid spt-grid--three">
<mat-form-field appearance="outline" class="spt-field">
<mat-label>Interest Calculate</mat-label>
<mat-select formControlName="interestCalculateSelector" required>
@for (mode of interestCalculationTypeData; track mode.id) {
<mat-option [value]="mode.id">{{ mode.value }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (savingProductTermsForm.value.interestCalculateSelector === 2) {
<mat-form-field appearance="outline" class="spt-field">
<mat-label>Average Period</mat-label>
<mat-select formControlName="averagePeriod">
@for (period of periodFrequencyData; track period.id) {
<mat-option [value]="period.value">{{ period.value }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" class="spt-field"> <mat-form-field appearance="outline" class="spt-field">
<mat-label>{{ 'labels.inputs.Interest Posting Period' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Interest Posting Period' | translate }}</mat-label>
@@ -82,47 +110,47 @@
<strong>{{ 'labels.commons.required' | translate }}</strong> <strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</div>
<div class="spt-footer">
<div class="spt-footer__grid">
<div class="spt-toggle-card">
<div>
<h4>Auto Interest Post</h4>
</div>
<div class="spt-toggle-card__choices">
@for (option of yesNoOptions; track option.value) {
<label class="inline-flex items-center cursor-pointer">
<input type="radio" class="w-4 h-4" formControlName="autoPost" [value]="option.value" />
<span class="ml-2 text-sm">{{ option.label }}</span>
</label>
}
</div>
</div>
<mat-form-field appearance="outline" class="spt-field"> <mat-form-field appearance="outline" class="spt-field">
<mat-label>{{ 'labels.inputs.Interest Calculated using' | translate }}</mat-label> <mat-label>Frequency (Fix Day)</mat-label>
<mat-select <input type="number" matInput formControlName="frequencyFixDay" />
formControlName="interestCalculationType" <mat-hint>Days</mat-hint>
matTooltip="{{ 'tooltips.The method used to calculate interest' | translate }}"
required
>
@for (interestCalculationType of interestCalculationTypeData; track interestCalculationType) {
<mat-option [value]="interestCalculationType.id">
{{ interestCalculationType.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
<mat-error>
{{ 'labels.inputs.Interest Calculated using' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="spt-field"> <mat-form-field appearance="outline" class="spt-field">
<mat-label>{{ 'labels.inputs.Days in Year' | translate }}</mat-label> <mat-label>Last Interest Date</mat-label>
<mat-select <input matInput [matDatepicker]="lastInterestDatePicker" formControlName="lastInterestDate" />
formControlName="interestCalculationDaysInYearType" <mat-datepicker-toggle matSuffix [for]="lastInterestDatePicker"></mat-datepicker-toggle>
matTooltip="{{ 'tooltips.To calculate interest' | translate }}" <mat-datepicker #lastInterestDatePicker></mat-datepicker>
required
>
@for (
interestCalculationDaysInYearType of interestCalculationDaysInYearTypeData;
track interestCalculationDaysInYearType
) {
<mat-option [value]="interestCalculationDaysInYearType.id">
{{ interestCalculationDaysInYearType.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
<mat-error>
{{ 'labels.inputs.Days in Year' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field> </mat-form-field>
</div> </div>
@if (savingProductTermsForm.value.interestCalculateSelector === 1) {
<div class="spt-note">
<span>⚠️</span>
<span
>Daily product-based interest accrual enabled - interest calculated EOD on daily closing balance.</span
>
</div>
}
</div>
</div>
</div> </div>
</form> </form>
@@ -44,17 +44,36 @@ $gray-900: #202124;
} }
} }
.spt-section__badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
background: #eff6ff;
font-size: 14px;
}
.spt-body {
background: #fff;
}
.spt-grid { .spt-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 4px 16px; gap: 4px 16px;
padding: 16px 24px; padding: 16px 24px;
@media (max-width: 600px) { @media (width <= 600px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
.spt-grid--three {
grid-template-columns: repeat(3, 1fr);
}
.spt-field { .spt-field {
width: 100%; width: 100%;
@@ -62,3 +81,67 @@ $gray-900: #202124;
grid-column: 1 / -1; grid-column: 1 / -1;
} }
} }
.spt-banner {
display: flex;
align-items: center;
gap: 10px;
margin: 16px 24px 0;
padding: 12px 14px;
background: #eff6ff;
border: 1px solid #dbeafe;
border-radius: 8px;
color: #1d4ed8;
font-size: 13px;
&__icon {
font-size: 14px;
}
}
.spt-footer {
padding: 0 24px 24px;
border-top: 1px solid #f1f5f9;
}
.spt-footer__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
padding-top: 18px;
@media (width <= 900px) {
grid-template-columns: 1fr;
}
}
.spt-toggle-card {
padding: 12px 0;
h4 {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
color: $gray-900;
}
&__choices {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
}
.spt-note {
display: flex;
align-items: center;
gap: 10px;
margin-top: 18px;
padding: 12px 14px;
border: 1px solid #fde68a;
background: #fffbeb;
color: #b45309;
border-radius: 8px;
font-size: 12px;
}
@@ -10,6 +10,8 @@ import { Component, OnInit, Input, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatTooltip } from '@angular/material/tooltip'; import { MatTooltip } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { Dates } from 'app/core/utils/dates';
import { SettingsService } from 'app/settings/settings.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({ @Component({
@@ -24,6 +26,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
}) })
export class SavingProductTermsStepComponent implements OnInit { export class SavingProductTermsStepComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder); private formBuilder = inject(UntypedFormBuilder);
private dateUtils = inject(Dates);
private settingsService = inject(SettingsService);
@Input() savingProductsTemplate: any; @Input() savingProductsTemplate: any;
@@ -32,8 +36,22 @@ export class SavingProductTermsStepComponent implements OnInit {
interestCompoundingPeriodTypeData: any; interestCompoundingPeriodTypeData: any;
interestPostingPeriodTypeData: any; interestPostingPeriodTypeData: any;
interestCalculationTypeData: any; interestCalculationTypeData: any;
interestCalculationTypeOptionData: any;
interestCalculationDaysInYearTypeData: any; interestCalculationDaysInYearTypeData: any;
periodFrequencyData: any[] = [];
yesNoOptions: Array<{
value: boolean;
label: string;
}> = [
{
value: true,
label: 'Yes'
},
{
value: false,
label: 'No'
}
];
constructor() { constructor() {
this.createSavingProductTermsForm(); this.createSavingProductTermsForm();
} }
@@ -42,15 +60,91 @@ export class SavingProductTermsStepComponent implements OnInit {
this.interestCompoundingPeriodTypeData = this.savingProductsTemplate.interestCompoundingPeriodTypeOptions; this.interestCompoundingPeriodTypeData = this.savingProductsTemplate.interestCompoundingPeriodTypeOptions;
this.interestPostingPeriodTypeData = this.savingProductsTemplate.interestPostingPeriodTypeOptions; this.interestPostingPeriodTypeData = this.savingProductsTemplate.interestPostingPeriodTypeOptions;
this.interestCalculationTypeData = this.savingProductsTemplate.interestCalculationTypeOptions; this.interestCalculationTypeData = this.savingProductsTemplate.interestCalculationTypeOptions;
this.interestCalculationTypeOptionData =
this.savingProductsTemplate.interestCalculationTypeOption ||
this.savingProductsTemplate.interestCalculationTypeOptions;
this.interestCalculationDaysInYearTypeData = this.savingProductsTemplate.interestCalculationDaysInYearTypeOptions; this.interestCalculationDaysInYearTypeData = this.savingProductsTemplate.interestCalculationDaysInYearTypeOptions;
this.periodFrequencyData = this.savingProductsTemplate.periodFrequencyOptions || [];
this.setYesNoOptions(this.savingProductsTemplate?.yesNoOptions);
const isEditing = !!this.savingProductsTemplate.id;
const defaultPeriod = this.periodFrequencyData[0]?.value || 'Monthly';
this.savingProductTermsForm.patchValue({ this.savingProductTermsForm.patchValue({
nominalAnnualInterestRate: this.savingProductsTemplate.nominalAnnualInterestRate, nominalAnnualInterestRate: isEditing
interestCompoundingPeriodType: this.savingProductsTemplate.interestCompoundingPeriodType.id, ? this.savingProductsTemplate.nominalAnnualInterestRate
interestPostingPeriodType: this.savingProductsTemplate.interestPostingPeriodType.id, : this.savingProductsTemplate.nominalAnnualInterestRate,
interestCalculationType: this.savingProductsTemplate.interestCalculationType.id, interestCompoundingPeriodType: isEditing
interestCalculationDaysInYearType: this.savingProductsTemplate.interestCalculationDaysInYearType.id ? this.savingProductsTemplate.interestCompoundingPeriodType?.id
: this.savingProductsTemplate.interestCompoundingPeriodType?.id,
interestPostingPeriodType: isEditing
? this.savingProductsTemplate.interestPostingPeriodType?.id
: this.savingProductsTemplate.interestPostingPeriodType?.id,
interestCalculationType: isEditing
? this.savingProductsTemplate.interestCalculationTypeOption?.id ||
this.interestCalculationTypeOptionData?.[0]?.id ||
1
: this.interestCalculationTypeOptionData?.[0]?.id || 1,
interestCalculationDaysInYearType: isEditing
? this.savingProductsTemplate.interestCalculationDaysInYearType?.id
: this.savingProductsTemplate.interestCalculationDaysInYearType?.id,
effectiveDate: this.savingProductsTemplate.effectiveDate
? new Date(this.savingProductsTemplate.effectiveDate)
: '',
interestCalculateSelector: isEditing
? this.savingProductsTemplate.interestCalculateSelector || 1
: this.savingProductsTemplate.interestCalculationType?.id || this.interestCalculationTypeData?.[0]?.id || 1,
averagePeriod: isEditing ? this.savingProductsTemplate.averagePeriod || defaultPeriod : defaultPeriod,
autoPost: isEditing ? (this.savingProductsTemplate.autoPost ?? true) : true,
frequencyFixDay: isEditing ? this.savingProductsTemplate.frequencyFixDay || 30 : 30,
lastInterestDate: this.savingProductsTemplate.lastInterestDate
? new Date(this.savingProductsTemplate.lastInterestDate)
: ''
}); });
// Map code value IDs to backend enum values for interestCalculationType
// Backend expects: 1=Daily Balance, 2=Average Daily Balance
const currentInterestCalcType = this.savingProductTermsForm.get('interestCalculationType').value;
if (currentInterestCalcType && currentInterestCalcType > 2) {
// If it's a code value ID (like 340, 341), map it to backend enum
// Default to 1 (Daily Balance) for custom code values
this.savingProductTermsForm.patchValue({
interestCalculationType: 1
});
}
}
private setYesNoOptions(options: any[]) {
if (!Array.isArray(options)) {
return;
}
const mappedOptions = options
.map((option: any) => {
const rawValue = option?.name ?? option?.value ?? option;
const normalized = typeof rawValue === 'string' ? rawValue.trim().toUpperCase() : rawValue;
if (normalized === 'YES') {
return {
value: true,
label: 'Yes'
};
}
if (normalized === 'NO') {
return {
value: false,
label: 'No'
};
}
return null;
})
.filter((value: any) => !!value);
if (mappedOptions.length) {
this.yesNoOptions = mappedOptions;
}
} }
createSavingProductTermsForm() { createSavingProductTermsForm() {
@@ -77,11 +171,41 @@ export class SavingProductTermsStepComponent implements OnInit {
interestCalculationDaysInYearType: [ interestCalculationDaysInYearType: [
'', '',
Validators.required Validators.required
] ],
effectiveDate: [''],
interestCalculateSelector: [1],
averagePeriod: ['Monthly'],
autoPost: [true],
frequencyFixDay: [
30,
[Validators.min(0)]
],
lastInterestDate: ['']
}); });
} }
get savingProductTerms() { get savingProductTerms() {
return this.savingProductTermsForm.value; const formValue = { ...this.savingProductTermsForm.value };
const dateFormat = this.settingsService.dateFormat;
const prevEffectiveDate: Date = this.savingProductTermsForm.value.effectiveDate;
const prevLastInterestDate: Date = this.savingProductTermsForm.value.lastInterestDate;
if (formValue.effectiveDate instanceof Date) {
formValue.effectiveDate = this.dateUtils.formatDate(prevEffectiveDate, dateFormat) || '';
}
if (formValue.lastInterestDate instanceof Date) {
formValue.lastInterestDate = this.dateUtils.formatDate(prevLastInterestDate, dateFormat) || '';
}
// Convert interestCalculationType from code value ID to backend enum value
// Backend expects: 1=Daily Balance, 2=Average Daily Balance
if (formValue.interestCalculationType && formValue.interestCalculationType > 2) {
formValue.interestCalculationType = 1; // Default to Daily Balance
}
return {
...formValue
};
} }
} }
@@ -24,7 +24,7 @@ export class SavingProductResolver {
private productsService = inject(ProductsService); private productsService = inject(ProductsService);
/** /**
* Returns the saving product data. * Returns the saving product data with template options for dropdown lookups.
* @returns {Observable<any>} * @returns {Observable<any>}
*/ */
resolve(route: ActivatedRouteSnapshot): Observable<any> { resolve(route: ActivatedRouteSnapshot): Observable<any> {
@@ -18,91 +18,213 @@
</button> </button>
</div> </div>
<div class="tab-container"> <div class="responsive-column">
<div class="responsive-column">
<h2 class="mat-h2 flex-100">{{ savingProduct.name }}</h2> <h2 class="mat-h2 flex-100">{{ savingProduct.name }}</h2>
<mat-divider [inset]="true"></mat-divider> <mat-divider class="flex-100"></mat-divider>
<h3 class="mat-h3 flex-100">{{ 'labels.inputs.Details' | translate }}</h3>
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Details' | translate }}</h3> <mat-divider class="flex-100"></mat-divider>
<div class="flex-100 layout-row">
<mat-divider [inset]="true"></mat-divider>
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Short Name' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Short Name' | translate }}:</span>
<span class="flex-60">{{ savingProduct.shortName }}</span> <span class="flex-60">{{ savingProduct.shortName }}</span>
</div> </div>
@if (savingProduct.description) { @if (savingProduct.description) {
<div class="flex-fill layout-row"> <div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Description' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Description' | translate }}:</span>
<span class="flex-60">{{ savingProduct.description }}</span> <span class="flex-60">{{ savingProduct.description }}</span>
</div> </div>
} }
<h3 class="mat-h3 flex-fill">{{ 'labels.inputs.Currency' | translate }}</h3> @if (
savingProduct.accountType ||
savingProduct.customerSegment ||
savingProduct.productCode ||
savingProduct.effectiveDate ||
savingProduct.effectiveTo ||
savingProduct.endDate ||
savingProduct.introducerRequired !== undefined ||
savingProduct.shareLinkingEnabled !== undefined
) {
<h3 class="mat-h3 flex-100">Product Classification</h3>
<mat-divider [inset]="true"></mat-divider> <mat-divider class="flex-100"></mat-divider>
<div class="flex-fill layout-row"> @if (savingProduct.accountType) {
<div class="flex-100 layout-row">
<span class="flex-40">Account Type:</span>
<span class="flex-60">{{ savingProduct.accountType }}</span>
</div>
}
@if (savingProduct.customerSegment) {
<div class="flex-100 layout-row">
<span class="flex-40">Customer Segment:</span>
<span class="flex-60">{{ savingProduct.customerSegment }}</span>
</div>
}
@if (savingProduct.productCode) {
<div class="flex-100 layout-row">
<span class="flex-40">Product Code:</span>
<span class="flex-60">{{ savingProduct.productCode }}</span>
</div>
}
@if (savingProduct.effectiveDate) {
<div class="flex-100 layout-row">
<span class="flex-40">Effective Date:</span>
<span class="flex-60">{{ savingProduct.effectiveDate }}</span>
</div>
}
@if (savingProduct.effectiveTo) {
<div class="flex-100 layout-row">
<span class="flex-40">Effective To:</span>
<span class="flex-60">{{ savingProduct.effectiveTo }}</span>
</div>
}
@if (savingProduct.endDate) {
<div class="flex-100 layout-row">
<span class="flex-40">End Date:</span>
<span class="flex-60">{{ savingProduct.endDate }}</span>
</div>
}
@if (savingProduct.introducerRequired !== undefined) {
<div class="flex-100 layout-row">
<span class="flex-40">Introducer Required:</span>
<span class="flex-60">{{
savingProduct.introducerRequired === true || savingProduct.introducerRequired === 'Yes' ? 'Yes' : 'No'
}}</span>
</div>
}
@if (savingProduct.shareLinkingEnabled !== undefined) {
<div class="flex-100 layout-row">
<span class="flex-40">Share Linking Enabled:</span>
<span class="flex-60">{{ savingProduct.shareLinkingEnabled | yesNo }}</span>
</div>
}
}
<h3 class="mat-h3 flex-100">{{ 'labels.inputs.Currency' | translate }}</h3>
<mat-divider class="flex-100"></mat-divider>
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Currency' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Currency' | translate }}:</span>
<span class="flex-60">{{ savingProduct.currency.name }}</span> <span class="flex-60">{{ savingProduct.currency.name }}</span>
</div> </div>
<div class="flex-fill layout-row"> <div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Decimal Places' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Decimal Places' | translate }}:</span>
<span class="flex-60">{{ savingProduct.currency.decimalPlaces }}</span> <span class="flex-60">{{ savingProduct.currency.decimalPlaces }}</span>
</div> </div>
<div class="flex-fill layout-row"> @if (savingProduct.currency.inMultiplesOf !== undefined && savingProduct.currency.inMultiplesOf !== null) {
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Currency in multiples of' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Currency in multiples of' | translate }}:</span>
<span class="flex-60">{{ savingProduct.currency.inMultiplesOf }}</span> <span class="flex-60">{{ savingProduct.currency.inMultiplesOf }}</span>
</div> </div>
}
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Terms' | translate }}</h3> @if (savingProduct.dormancyAlert !== undefined) {
<div class="flex-100 layout-row">
<span class="flex-40">Dormancy Alert:</span>
<span class="flex-60">{{ savingProduct.dormancyAlert | yesNo }}</span>
</div>
}
<mat-divider [inset]="true"></mat-divider> @if (savingProduct.nominationFacility !== undefined) {
<div class="flex-100 layout-row">
<span class="flex-40">Nomination Facility:</span>
<span class="flex-60">{{ savingProduct.nominationFacility | yesNo }}</span>
</div>
}
<div class="flex-fill layout-row"> <h3 class="mat-h3 flex-100">{{ 'labels.inputs.Terms' | translate }}</h3>
<mat-divider class="flex-100"></mat-divider>
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Nominal Annual Interest' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Nominal Annual Interest' | translate }}:</span>
<span class="flex-60">{{ savingProduct.nominalAnnualInterestRate | formatNumber }} %</span> <span class="flex-60">{{ savingProduct.nominalAnnualInterestRate | formatNumber }} %</span>
</div> </div>
<div class="flex-fill layout-row"> <div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Interest Compounding Period' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Interest Compounding Period' | translate }}:</span>
<span class="flex-60">{{ savingProduct.interestCompoundingPeriodType.value | translateKey: 'catalogs' }}</span> <span class="flex-60">{{ savingProduct.interestCompoundingPeriodType.value | translateKey: 'catalogs' }}</span>
</div> </div>
<div class="flex-fill layout-row"> <div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Interest Posting Period' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Interest Posting Period' | translate }}:</span>
<span class="flex-60">{{ savingProduct.interestPostingPeriodType.value | translateKey: 'catalogs' }}</span> <span class="flex-60">{{ savingProduct.interestPostingPeriodType.value | translateKey: 'catalogs' }}</span>
</div> </div>
<div class="flex-fill layout-row"> <div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Interest Calculated using' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Interest Calculated using' | translate }}:</span>
<span class="flex-60">{{ savingProduct.interestCalculationType.value | translateKey: 'catalogs' }}</span> <span class="flex-60">{{ savingProduct.interestCalculationType.value | translateKey: 'catalogs' }}</span>
</div> </div>
<div class="flex-fill layout-row"> <div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Days in Year' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Days in Year' | translate }}:</span>
<span class="flex-60">{{ <span class="flex-60">{{ savingProduct.interestCalculationDaysInYearType.value | translateKey: 'catalogs' }}</span>
savingProduct.interestCalculationDaysInYearType.value | translateKey: 'catalogs'
}}</span>
</div> </div>
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Settings' | translate }}</h3> @if (savingProduct.effectiveDate) {
<div class="flex-100 layout-row">
<span class="flex-40">Effective Date:</span>
<span class="flex-60">{{ savingProduct.effectiveDate }}</span>
</div>
}
<mat-divider [inset]="true"></mat-divider> <div class="flex-100 layout-row">
<span class="flex-40">Interest Calculate:</span>
<span class="flex-60">{{ savingProduct.interestCalculateSelector }}</span>
</div>
@if (savingProduct.averagePeriod) {
<div class="flex-100 layout-row">
<span class="flex-40">Average Period:</span>
<span class="flex-60">{{ savingProduct.averagePeriod }}</span>
</div>
}
<div class="flex-100 layout-row">
<span class="flex-40">Auto Post:</span>
<span class="flex-60">{{ savingProduct.autoPost | yesNo }}</span>
</div>
@if (savingProduct.frequencyFixDay) {
<div class="flex-100 layout-row">
<span class="flex-40">Frequency Fix Day:</span>
<span class="flex-60">{{ savingProduct.frequencyFixDay }}</span>
</div>
}
@if (savingProduct.lastInterestDate) {
<div class="flex-100 layout-row">
<span class="flex-40">Last Interest Date:</span>
<span class="flex-60">{{ savingProduct.lastInterestDate }}</span>
</div>
}
<h3 class="mat-h3 flex-100">{{ 'labels.inputs.Settings' | translate }}</h3>
<mat-divider class="flex-100"></mat-divider>
@if (savingProduct.minRequiredOpeningBalance) { @if (savingProduct.minRequiredOpeningBalance) {
<div class="flex-fill layout-row"> <div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Minimum Opening Balance' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Minimum Opening Balance' | translate }}:</span>
<span class="flex-60">{{ savingProduct.minRequiredOpeningBalance | formatNumber }}</span> <span class="flex-60">{{ savingProduct.minRequiredOpeningBalance | formatNumber }}</span>
</div> </div>
} }
@if (savingProduct.lockinPeriodFrequency) { @if (savingProduct.lockinPeriodFrequency) {
<div class="flex-fill layout-row"> <div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Lock-in Period' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Lock-in Period' | translate }}:</span>
<span class="flex-60">{{ <span class="flex-60">{{
savingProduct.lockinPeriodFrequency + ' ' + savingProduct.lockinPeriodFrequencyType.value savingProduct.lockinPeriodFrequency + ' ' + savingProduct.lockinPeriodFrequencyType.value
@@ -110,77 +232,94 @@
</div> </div>
} }
<div class="flex-fill layout-row"> <div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }}:</span>
<span class="flex-60">{{ savingProduct.withdrawalFeeForTransfers | yesNo }}</span> <span class="flex-60">{{ savingProduct.withdrawalFeeForTransfers | yesNo }}</span>
</div> </div>
@if (savingProduct.balanceFrequency) {
<div class="flex-100 layout-row">
<span class="flex-40">Balance Frequency:</span>
<span class="flex-60">{{ savingProduct.balanceFrequency }}</span>
</div>
}
<div class="flex-100 layout-row">
<span class="flex-40">Enable Lock-in Period:</span>
<span class="flex-60">{{ savingProduct.enableLockinPeriod | yesNo }}</span>
</div>
@if (savingProduct.minBalanceForInterestCalculation) { @if (savingProduct.minBalanceForInterestCalculation) {
<div class="flex-fill layout-row"> <div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}:</span>
<span class="flex-60">{{ savingProduct.minBalanceForInterestCalculation | formatNumber }}</span> <span class="flex-60">{{ savingProduct.minBalanceForInterestCalculation | formatNumber }}</span>
</div> </div>
} }
<div class="flex-fill layout-row"> <div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Enforce Minimum Balance' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Enforce Minimum Balance' | translate }}:</span>
<span class="flex-60">{{ savingProduct.enforceMinRequiredBalance | yesNo }}</span> <span class="flex-60">{{ savingProduct.enforceMinRequiredBalance | yesNo }}</span>
</div> </div>
@if (savingProduct.minRequiredBalance) { @if (savingProduct.minRequiredBalance) {
<div class="flex-fill layout-row"> <div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Minimum Balance' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Minimum Balance' | translate }}:</span>
<span class="flex-60">{{ savingProduct.minRequiredBalance | formatNumber }}</span> <span class="flex-60">{{ savingProduct.minRequiredBalance | formatNumber }}</span>
</div> </div>
} }
<div class="flex-fill layout-row"> <div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Withhold Tax is Applicable' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Withhold Tax is Applicable' | translate }}:</span>
<span class="flex-60">{{ savingProduct.withHoldTax | yesNo }}</span> <span class="flex-60">{{ savingProduct.withHoldTax | yesNo }}</span>
</div> </div>
@if (savingProduct.withHoldTax) { @if (savingProduct.withHoldTax) {
<div class="flex-fill layout-row"> <div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Tax Group' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Tax Group' | translate }}:</span>
<span class="flex-60">{{ savingProduct.taxGroup.name }}</span> <span class="flex-60">{{ savingProduct.taxGroup.name }}</span>
</div> </div>
} }
<div class="flex-fill layout-row"> <div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Is Overdraft Allowed' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Is Overdraft Allowed' | translate }}:</span>
<span class="flex-60">{{ savingProduct.allowOverdraft | yesNo }}</span> <span class="flex-60">{{ savingProduct.allowOverdraft | yesNo }}</span>
</div> </div>
<div class="flex-100 layout-row">
<span class="flex-40">Account Closed Zero Balance in Day Closing:</span>
<span class="flex-60">{{ savingProduct.accountClosedZeroBalanceInDayClosing | yesNo }}</span>
</div>
@if (savingProduct.minOverdraftForInterestCalculation) { @if (savingProduct.minOverdraftForInterestCalculation) {
<div class="flex-fill layout-row"> <div class="flex-100 layout-row">
<span class="flex-40" <span class="flex-40"
>{{ 'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate }}:</span >{{ 'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate }}:</span
> >
<span class="flex-60">{{ savingProduct.minOverdraftForInterestCalculation }}</span> <span class="flex-60">{{ savingProduct.minOverdraftForInterestCalculation | formatNumber }}</span>
</div> </div>
} }
@if (savingProduct.nominalAnnualInterestRateOverdraft) { @if (savingProduct.nominalAnnualInterestRateOverdraft) {
<div class="flex-fill layout-row"> <div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Nominal Annual Interest for Overdraft' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Nominal Annual Interest for Overdraft' | translate }}:</span>
<span class="flex-60">{{ savingProduct.nominalAnnualInterestRateOverdraft }} %</span> <span class="flex-60">{{ savingProduct.nominalAnnualInterestRateOverdraft | formatNumber }} %</span>
</div> </div>
} }
@if (savingProduct.overdraftLimit) { @if (savingProduct.overdraftLimit) {
<div class="flex-fill layout-row"> <div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Maximum Overdraft Amount Limit' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Maximum Overdraft Amount Limit' | translate }}:</span>
<span class="flex-60">{{ savingProduct.overdraftLimit | formatNumber }}</span> <span class="flex-60">{{ savingProduct.overdraftLimit | formatNumber }}</span>
</div> </div>
} }
<div class="flex-fill layout-row"> <div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Enable Dormancy Tracking' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Enable Dormancy Tracking' | translate }}:</span>
<span class="flex-60">{{ savingProduct.isDormancyTrackingActive | yesNo }}</span> <span class="flex-60">{{ savingProduct.isDormancyTrackingActive | yesNo }}</span>
</div> </div>
@if (savingProduct.isDormancyTrackingActive) { @if (savingProduct.isDormancyTrackingActive) {
<div class="layout-row-wrap responsive-column flex-fill"> <div class="layout-row-wrap responsive-column flex-100">
<span class="flex-40">{{ 'labels.inputs.Number of Days to Inactive sub-status' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Number of Days to Inactive sub-status' | translate }}:</span>
<span class="flex-60">{{ savingProduct.daysToInactive }}</span> <span class="flex-60">{{ savingProduct.daysToInactive }}</span>
<span class="flex-40">{{ 'labels.inputs.Number of Days to Dormant sub-status' | translate }}:</span> <span class="flex-40">{{ 'labels.inputs.Number of Days to Dormant sub-status' | translate }}:</span>
@@ -190,11 +329,85 @@
</div> </div>
} }
<h3 class="mat-h3 flex-100">Account Facilities</h3>
<mat-divider class="flex-100"></mat-divider>
@if (savingProduct.accountFacilityConfig) {
<div class="layout-row-wrap responsive-column flex-100">
@for (feature of accountFacilityFeatureItems; track feature.key) {
@if (savingProduct.accountFacilityConfig[feature.key] !== undefined) {
<div class="flex-100 layout-row">
<span class="flex-40">{{ feature.label }}:</span>
<span class="flex-60">{{ savingProduct.accountFacilityConfig[feature.key] | yesNo }}</span>
</div>
}
}
</div>
}
@if (savingProduct.accountFacilityLimits?.length) {
<div class="layout-row-wrap responsive-column flex-100">
<h4 class="mat-h4 flex-100">Account Facility Limits</h4>
<mat-divider class="flex-100"></mat-divider>
<table class="flex-100 mat-elevation-z1" mat-table [dataSource]="savingProduct.accountFacilityLimits">
<ng-container matColumnDef="limitType">
<th mat-header-cell *matHeaderCellDef>Limit Type</th>
<td mat-cell *matCellDef="let row">{{ row.limitType }}</td>
</ng-container>
<ng-container matColumnDef="generalLimit">
<th mat-header-cell *matHeaderCellDef>General Limit</th>
<td mat-cell *matCellDef="let row">{{ row.generalLimit }}</td>
</ng-container>
<ng-container matColumnDef="minorAccountLimit">
<th mat-header-cell *matHeaderCellDef>Minor Account Limit</th>
<td mat-cell *matCellDef="let row">{{ row.minorAccountLimit }}</td>
</ng-container>
<ng-container matColumnDef="smallSavingLimit">
<th mat-header-cell *matHeaderCellDef>Small Saving Limit</th>
<td mat-cell *matCellDef="let row">{{ row.smallSavingLimit }}</td>
</ng-container>
<tr
mat-header-row
*matHeaderRowDef="['limitType', 'generalLimit', 'minorAccountLimit', 'smallSavingLimit']"
></tr>
<tr
mat-row
*matRowDef="let row; columns: ['limitType', 'generalLimit', 'minorAccountLimit', 'smallSavingLimit']"
></tr>
</table>
</div>
}
@if (savingProduct.depositLimitKyc) {
<div class="layout-row-wrap responsive-column flex-100">
<h4 class="mat-h4 flex-100">Deposit Limit (KYC)</h4>
<div class="flex-100 layout-row">
<span class="flex-40">General:</span>
<span class="flex-60"
>{{ savingProduct.depositLimitKyc.general.kycType }} - {{ savingProduct.depositLimitKyc.general.limit }}</span
>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Minor:</span>
<span class="flex-60"
>{{ savingProduct.depositLimitKyc.minor.kycType }} - {{ savingProduct.depositLimitKyc.minor.limit }}</span
>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Small Saving:</span>
<span class="flex-60"
>{{ savingProduct.depositLimitKyc.smallSaving.kycType }} -
{{ savingProduct.depositLimitKyc.smallSaving.limit }}</span
>
</div>
</div>
}
@if (savingProduct.charges.length) { @if (savingProduct.charges.length) {
<div class="layout-row-wrap responsive-column flex-fill"> <div class="layout-row-wrap responsive-column flex-100">
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Charges' | translate }}</h3> <h3 class="mat-h3 flex-100">{{ 'labels.heading.Charges' | translate }}</h3>
<mat-divider [inset]="true"></mat-divider> <mat-divider class="flex-100"></mat-divider>
<table class="flex-fill mat-elevation-z1" mat-table [dataSource]="savingProduct.charges"> <table class="flex-100 mat-elevation-z1" mat-table [dataSource]="savingProduct.charges">
<ng-container matColumnDef="name"> <ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Name' | translate }}</th> <th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Name' | translate }}</th>
<td mat-cell *matCellDef="let charge"> <td mat-cell *matCellDef="let charge">
@@ -225,9 +438,9 @@
</div> </div>
} }
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Accounting' | translate }}</h3> <h3 class="mat-h3 flex-100">{{ 'labels.heading.Accounting' | translate }}</h3>
<mat-divider [inset]="true"></mat-divider> <mat-divider class="flex-100"></mat-divider>
<mifosx-view-savings-accounting-details <mifosx-view-savings-accounting-details
class="flex-100" class="flex-100"
@@ -241,14 +454,14 @@
savingProduct.feeToIncomeAccountMappings?.length || savingProduct.feeToIncomeAccountMappings?.length ||
savingProduct.penaltyToIncomeAccountMappings?.length savingProduct.penaltyToIncomeAccountMappings?.length
) { ) {
<div class="flex-fill layout-row-wrap responsive-column"> <div class="flex-100 layout-row-wrap responsive-column">
<h3 class="mat-h3 flex-fill">{{ 'labels.inputs.Advanced Accounting Rules' | translate }}</h3> <h3 class="mat-h3 flex-100">{{ 'labels.inputs.Advanced Accounting Rules' | translate }}</h3>
<mat-divider [inset]="true"></mat-divider> <mat-divider class="flex-100"></mat-divider>
@if (savingProduct.paymentChannelToFundSourceMappings?.length) { @if (savingProduct.paymentChannelToFundSourceMappings?.length) {
<div class="flex-fill layout-row-wrap responsive-column"> <div class="flex-100 layout-row-wrap responsive-column">
<h4 class="mat-h4 flex-fill">{{ 'labels.inputs.Fund Sources for Payment Channels' | translate }}</h4> <h4 class="mat-h4 flex-100">{{ 'labels.inputs.Fund Sources for Payment Channels' | translate }}</h4>
<table <table
class="mat-elevation-z1 flex-fill" class="mat-elevation-z1 flex-100"
mat-table mat-table
[dataSource]="savingProduct.paymentChannelToFundSourceMappings" [dataSource]="savingProduct.paymentChannelToFundSourceMappings"
> >
@@ -270,20 +483,18 @@
</div> </div>
} }
@if (savingProduct.feeToIncomeAccountMappings?.length) { @if (savingProduct.feeToIncomeAccountMappings?.length) {
<div class="flex-fill layout-row-wrap responsive-column"> <div class="flex-100 layout-row-wrap responsive-column">
<h4 class="mat-h4 flex-fill">{{ 'labels.inputs.Fees to Specific Income Accounts' | translate }}</h4> <h4 class="mat-h4 flex-100">{{ 'labels.inputs.Fees to Specific Income Accounts' | translate }}</h4>
<table class="flex-fill mat-elevation-z1" mat-table [dataSource]="savingProduct.feeToIncomeAccountMappings"> <table class="flex-100 mat-elevation-z1" mat-table [dataSource]="savingProduct.feeToIncomeAccountMappings">
<ng-container matColumnDef="chargeId"> <ng-container matColumnDef="chargeId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fees' | translate }}</th> <th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fee/Charge' | translate }}</th>
<td mat-cell *matCellDef="let feesIncome"> <td mat-cell *matCellDef="let fee">
{{ feesIncome.charge.name }} {{ fee.charge.name }}
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="incomeAccountId"> <ng-container matColumnDef="incomeAccountId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Income Account' | translate }}</th> <th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Income Account' | translate }}</th>
<td mat-cell *matCellDef="let feesIncome"> <td mat-cell *matCellDef="let fee">({{ fee.incomeAccount.glCode }}) {{ fee.incomeAccount.name }}</td>
({{ feesIncome.incomeAccount.glCode }}) {{ feesIncome.incomeAccount.name }}
</td>
</ng-container> </ng-container>
<tr mat-header-row *matHeaderRowDef="feesPenaltyIncomeDisplayedColumns"></tr> <tr mat-header-row *matHeaderRowDef="feesPenaltyIncomeDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: feesPenaltyIncomeDisplayedColumns"></tr> <tr mat-row *matRowDef="let row; columns: feesPenaltyIncomeDisplayedColumns"></tr>
@@ -291,23 +502,23 @@
</div> </div>
} }
@if (savingProduct.penaltyToIncomeAccountMappings?.length) { @if (savingProduct.penaltyToIncomeAccountMappings?.length) {
<div class="flex-fill layout-row-wrap responsive-column"> <div class="flex-100 layout-row-wrap responsive-column">
<h4 class="mat-h4 flex-fill">{{ 'labels.inputs.Penalties to Specific Income Accounts' | translate }}</h4> <h4 class="mat-h4 flex-100">{{ 'labels.inputs.Penalties to Specific Income Accounts' | translate }}</h4>
<table <table
class="mat-elevation-z1 flex-fill" class="flex-100 mat-elevation-z1"
mat-table mat-table
[dataSource]="savingProduct.penaltyToIncomeAccountMappings" [dataSource]="savingProduct.penaltyToIncomeAccountMappings"
> >
<ng-container matColumnDef="chargeId"> <ng-container matColumnDef="chargeId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Penalty' | translate }}</th> <th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Penalty' | translate }}</th>
<td mat-cell *matCellDef="let penaltyIncome"> <td mat-cell *matCellDef="let penalty">
{{ penaltyIncome.charge.name }} {{ penalty.charge.name }}
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="incomeAccountId"> <ng-container matColumnDef="incomeAccountId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Income Account' | translate }}</th> <th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Income Account' | translate }}</th>
<td mat-cell *matCellDef="let penaltyIncome"> <td mat-cell *matCellDef="let penalty">
({{ penaltyIncome.incomeAccount.glCode }}) {{ penaltyIncome.incomeAccount.name }} ({{ penalty.incomeAccount.glCode }}) {{ penalty.incomeAccount.name }}
</td> </td>
</ng-container> </ng-container>
<tr mat-header-row *matHeaderRowDef="feesPenaltyIncomeDisplayedColumns"></tr> <tr mat-header-row *matHeaderRowDef="feesPenaltyIncomeDisplayedColumns"></tr>
@@ -317,5 +528,4 @@
} }
</div> </div>
} }
</div>
</div> </div>
@@ -71,3 +71,95 @@ mat-divider {
.margin-t { .margin-t {
margin-top: 1em; margin-top: 1em;
} }
.spa-gl-summary,
.spa-other-gl-summary {
display: flex;
flex-direction: column;
gap: 0.9rem;
margin-top: 1.25rem;
}
.spa-gl-summary__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0 0.5rem;
}
.spa-gl-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem 1.15rem;
border: 1px solid #e3e7f2;
border-radius: 12px;
background: #fff;
}
.spa-gl-section h5 {
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #91a0bc;
}
.spa-gl-row {
display: grid;
grid-template-columns: minmax(220px, 40%) 1fr;
gap: 1rem;
align-items: baseline;
padding: 0.2rem 0;
}
.spa-gl-row__label {
color: #1f2f4a;
font-weight: 500;
}
.spa-gl-row__value {
color: #101828;
font-weight: 600;
word-break: break-word;
}
.spa-other-gl-table {
margin-top: 0.5rem;
margin-bottom: 0.75rem;
table-layout: fixed;
width: 100%;
}
.spa-other-gl-table:first-of-type {
margin-top: 0.25rem;
}
.spa-other-gl-summary .mat-elevation-z1 {
border-radius: 10px;
overflow: hidden;
}
.spa-other-gl-table th.mat-mdc-header-cell,
.spa-other-gl-table td.mat-mdc-cell {
padding-left: 1.25rem;
padding-right: 1.25rem;
}
.spa-other-gl-table th.mat-mdc-header-cell:first-child,
.spa-other-gl-table td.mat-mdc-cell:first-child {
width: 38%;
}
.spa-other-gl-table th.mat-mdc-header-cell:last-child,
.spa-other-gl-table td.mat-mdc-cell:last-child {
width: 62%;
word-break: break-word;
}
@media (width <= 768px) {
.spa-gl-row {
grid-template-columns: 1fr;
gap: 0.35rem;
}
}
@@ -72,6 +72,25 @@ export class SavingProductGeneralTabComponent {
'incomeAccountId' 'incomeAccountId'
]; ];
accountFacilityFeatureItems = [
{ key: 'atmFacility', label: 'ATM Facility' },
{ key: 'atmWithdrawalFacility', label: 'ATM Withdrawal Facility' },
{ key: 'upiFacility', label: 'UPI Facility' },
{ key: 'posTransactionsFacility', label: 'POS Transactions Facility' },
{ key: 'chequeBook', label: 'Cheque Book' },
{ key: 'impsFacility', label: 'IMPS Facility' },
{ key: 'smsAlerts', label: 'SMS Alerts' },
{ key: 'bbpsFacility', label: 'BBPS Facility' },
{ key: 'insuranceCover', label: 'Insurance Cover' },
{ key: 'bepgFacility', label: 'BEPG Facility' },
{ key: 'neftFacility', label: 'NEFT Facility' },
{ key: 'rtgsFacility', label: 'RTGS Facility' },
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap Facility' },
{ key: 'withdrawalFacility', label: 'Withdrawal Facility' },
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' }
];
constructor() { constructor() {
this.route.data.subscribe((data: { savingProduct: any }) => { this.route.data.subscribe((data: { savingProduct: any }) => {
this.savingProduct = data.savingProduct; this.savingProduct = data.savingProduct;
@@ -85,4 +104,22 @@ export class SavingProductGeneralTabComponent {
isAccrualAccounting(): boolean { isAccrualAccounting(): boolean {
return this.accounting.isAccrualAccounting(this.savingProduct.accountingRule); return this.accounting.isAccrualAccounting(this.savingProduct.accountingRule);
} }
formatGlAccountById(glAccountId: number, options: any[]): string {
if (!glAccountId || !Array.isArray(options)) {
return '-';
}
const account = this.accounting.glAccountLookUp(glAccountId, options as any);
return account ? `(${account.glCode}) ${account.name}` : '-';
}
formatGlAccountByCode(glCode: string, options: any[]): string {
if (!glCode || !Array.isArray(options)) {
return '-';
}
const account = options.find((option) => option?.glCode === glCode);
return account ? `(${account.glCode}) ${account.name}` : glCode;
}
} }
+2 -3
View File
@@ -5,13 +5,12 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
// IMPORTANT: THIS FILE IS AUTO GENERATED! DO NOT MANUALLY EDIT OR CHECKIN! // IMPORTANT: THIS FILE IS AUTO GENERATED! DO NOT MANUALLY EDIT OR CHECKIN!
/* tslint:disable */ /* tslint:disable */
export default { export default {
'mifos_x': { 'mifos_x': {
'version': '260407', 'version': '260413',
'hash': 'gb890d24bb' 'hash': '79e455c4'
}, },
'allow_switching_backend_instance': true 'allow_switching_backend_instance': true
}; };