Compare commits

..
5 Commits
Author SHA1 Message Date
niteshandClaude Sonnet 5 1eaa8cc8cf feat(fd): make the Payment dialog the FD Dashboard's Renew action too, redesign it
The Payment dialog (Transfer to Savings / Reinvest Full / Reinvest Principal Only / Partial
Reinvest, works whether an FD is Active or Matured) already had a complete, correct backend
implementation for every closure type -- including one this session found and fixed a real gap
in on the backend side (Reinvest Principal Only silently stranded its leftover interest). Rather
than duplicating that logic in a second, narrower dialog, the FD Dashboard's Renew action now
opens this same dialog in a `dashboardRenewal` mode: every closure type is still available, but
the Closed On Date is computed from the product's grace window instead of picked freely
(backdated to the original maturity date within grace, today once it's passed, picker hidden so
it can't be moved off that value), and submit() calls the new gap-interest pre-step endpoint
first when past grace, before the same generic `payment` command every other caller already used.

Also: full visual redesign (bigger dialog, gradient header, per-option accent colors, refined
spacing) applied to both entry points since they're the same component now; removes the native
"Close" action from a Matured FD's Account Actions menu, since what happens at maturity now
goes through the nightly job or this dialog, not a separate close command that bypassed both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:12:45 +05:30
niteshandClaude Sonnet 5 a652ce1093 feat(fd): make Maturity Instructions optional at account-opening, pre-fill it on edit
Maturity Instructions can now be left unselected when opening a SINGLE/DOUBLE/SPECIAL FD --
a new "-- Not selected (decide later via FD Dashboard) --" option, plus a clear button next to
the field, since a maker had no way to go back to blank once something was picked. An account
left this way is never touched by the nightly maturity job; what happens to it at maturity is
decided later, manually, from the FD Dashboard's Renew action (see the backend/dashboard
commits) -- an explicit choice here still auto-processes on its own exactly as before.

Also fixes a real, separate pre-existing bug found in the same step: editing an existing
account never read back its own already-saved Maturity Instructions/transfer-target choice --
the Edit screen always opened onto a blank picker even for an account that genuinely had one
set, because it was being read from the wrong template object (the blank per-product default
template refetched from the Product dropdown, not the actual account+template response that
carries the account's own saved values).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:08:51 +05:30
niteshandClaude Sonnet 5 50b3542dfe fix(products): show only genuinely-changed fields in the product-edit diff
Three separate bugs in the checker's "Proposed changes" dialog, found while adding the
Renewal Grace Period field above:

- The "current" side resolved an enum field's value against the wrong payload key
  (preClosurePenalInterestOnTypeId, minDepositTermTypeId, etc. are exposed on the stored
  product as an object under a differently-named field, e.g. preClosurePenalInterestOnType.id)
  so it always read as blank -- these fields showed as changed on every single edit, never
  just the ones a maker actually touched.
- Enum/option fields resolved their label against a naive `<field>Options` key that doesn't
  match Fineract's actual (inconsistent) naming, so a genuinely changed field showed its raw
  numeric id instead of the option a maker picked.
- Renewal Grace Period Days and other newer fields never appeared at all, because the
  maker-checker pending-edit mirror table didn't carry them (see the backend commit for that).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:03:15 +05:30
niteshandClaude Sonnet 5 89c091671f feat(fd): add Renewal Grace Period (Days) field to FD product Terms step
Frontend half of the new per-product grace-period field: number input alongside the existing
term fields, and stops forcing real default values (1 / the first period-frequency option) onto
minDepositTerm/minDepositTermTypeId, two hidden fields nobody actually edits through this form
-- left blank like their max-/in-multiples-of- siblings already correctly were, so a governed
product edit's "Proposed changes" diff no longer showed these as changed on every unrelated edit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:02:35 +05:30
niteshandClaude Sonnet 5 865ad8ba88 feat(fd): add FD Dashboard -- maturity ladder + manual Renew action
New screen (Deposits menu, repurposing the dead "FD Maturity Report" shell button): every
active Fixed Deposit positioned against its own maturity date (Matured / Due Soon / Upcoming /
On Track), computed client-side from maturityDate so it can never drift out of step with the
account. Reads the new lean maturity-dashboard backend endpoint.

A matured, renewable row (no maturity instruction chosen at account-opening, or an explicit
Reinvest instruction still waiting on its product's grace period) gets a Renew action wired to
the account's own Payment dialog -- see the FD account-view commit for that side of it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:01:43 +05:30
23 changed files with 2063 additions and 276 deletions
+11
View File
@@ -74,6 +74,17 @@ export class AccountsService {
return this.http.get('/fixeddepositaccounts', { params: httpParams });
}
/**
* FD Dashboard's data source: every ACTIVE FD, id/account/client/product identity, maturity date
* and deposit/maturity amounts only. Deliberately a separate, narrow endpoint rather than
* getFixedDepositAccounts() above -- that one's backend query joins in six more tables (audit
* who-did-what-when, currency, tax group) an unbounded dashboard fetch does not need, over columns
* that had no index until this feature added one (see backend migration 0571).
*/
getFixedDepositMaturityDashboard(): Observable<any> {
return this.http.get('/fixeddepositaccounts/maturity-dashboard');
}
getRecurringDepositAccounts(params: AccountListParams = {}): Observable<any> {
let httpParams = new HttpParams()
.set('paged', 'true')
+2
View File
@@ -63,6 +63,7 @@ import { CfcfrmsModule } from './cfcfrms/cfcfrms.module';
import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
import { DeafModule } from './deaf/deaf.module';
import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module';
import { FdDashboardModule } from './fd-dashboard/fd-dashboard.module';
import { DicgcModule } from './dicgc/dicgc.module';
import { FiuModule } from './fiu/fiu.module';
import { GlReportsModule } from './gl-reports/gl-reports.module';
@@ -176,6 +177,7 @@ export function HttpLoaderFactory(http: HttpClient) {
BankCalendarModule,
DeafModule,
SavingsDailyInterestModule,
FdDashboardModule,
DicgcModule,
FiuModule,
GlReportsModule,
+10 -2
View File
@@ -65,6 +65,14 @@
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()"> <kbd>ESC</kbd> Show Header </span>
</nav>
}
<!-- FD Dashboard fullscreen — ESC hint bar -->
@else if (currentUrl.includes('/fd-dashboard')) {
<nav class="cashier-nav cashier-nav--election">
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()">
<kbd>ESC</kbd> Exit Fullscreen (Show Header &amp; Footer)
</span>
</nav>
}
<!-- Clerk Dashboard fullscreen nav — only visible at /clerk/* -->
@else if (currentUrl.includes('/clerk/')) {
<nav class="cashier-nav cashier-nav--clerk">
@@ -1203,8 +1211,8 @@
<button mat-menu-item tabindex="0">
<fa-icon icon="clock" class="menu-item-icon"></fa-icon> Account Expiry
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> FD Maturity Report
<button mat-menu-item tabindex="0" [routerLink]="['/fd-dashboard']">
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> FD Dashboard
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> FD Interest Report
+9 -7
View File
@@ -167,7 +167,9 @@ export class ShellComponent implements OnInit, OnDestroy {
url.includes('/clerk/withdrawal-request') ||
url.includes('/clerk/my-disbursement-requests');
const isElectionRoute = url.includes('/election');
this.hideShellLayout = isCashierOnly || isClerkRoute || isElectionRoute || this.isWithdrawalReviewUrl;
const isFdDashboardRoute = url.includes('/fd-dashboard');
this.hideShellLayout =
isCashierOnly || isClerkRoute || isElectionRoute || isFdDashboardRoute || this.isWithdrawalReviewUrl;
this.cdr.detectChanges();
};
this.routeSub = this.router.events.pipe(filter((e) => e instanceof NavigationEnd)).subscribe(() => checkLayout());
@@ -180,10 +182,7 @@ export class ShellComponent implements OnInit, OnDestroy {
});
}
/**
* Toggles the current collapsed state of sidenav according to the emitted event.
* @param {boolean} event denotes state of sidenav
*/
/** Toggles the current collapsed state of sidenav according to the emitted event. */
toggleCollapse($event: boolean) {
this.sidenavCollapsed = $event;
this.cdr.detectChanges();
@@ -204,12 +203,15 @@ export class ShellComponent implements OnInit, OnDestroy {
return /\/checker-inbox-and-tasks\/(share-)?withdrawal-requests\/\d+\/review/.test(this.router.url);
}
/** ESC key — exit fullscreen at Clerk Dashboard / Election / review screen, restoring full shell header. */
/** ESC key — exit fullscreen at Clerk Dashboard / Election / FD Dashboard / review screen, restoring full shell header. */
@HostListener('document:keydown.escape')
onEscapeKey(): void {
if (
this.hideShellLayout &&
(this.router.url.includes('/clerk/') || this.router.url.includes('/election') || this.isWithdrawalReviewUrl)
(this.router.url.includes('/clerk/') ||
this.router.url.includes('/election') ||
this.router.url.includes('/fd-dashboard') ||
this.isWithdrawalReviewUrl)
) {
this.hideShellLayout = false;
this.cdr.detectChanges();
@@ -7,17 +7,29 @@
-->
<form [formGroup]="closureSettingsForm" class="layout-row-wrap gap-2px responsive-column">
<h4 class="mat-h4 flex-98">{{ 'labels.heading.For Pre-mature closure' | translate }}</h4>
<h4 class="mat-h4 flex-98">{{ 'labels.heading.Maturity Instructions' | translate }}</h4>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Maturity Instructions' | translate }}</mat-label>
<mat-select formControlName="maturityInstructionId">
<mat-option [value]="''">-- Not selected (decide later via FD Dashboard) --</mat-option>
@for (maturityInstruction of maturityInstructionOptions; track maturityInstruction) {
<mat-option [value]="maturityInstruction.id">
{{ maturityInstruction.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
@if (closureSettingsForm.value.maturityInstructionId) {
<button
mat-icon-button
matSuffix
type="button"
title="Clear -- decide later via FD Dashboard"
(click)="$event.stopPropagation(); closureSettingsForm.get('maturityInstructionId')?.setValue('')"
>
<mat-icon>close</mat-icon>
</button>
}
</mat-form-field>
@if (closureSettingsForm.contains('transferToSavingsId')) {
@@ -28,8 +40,8 @@
<mat-icon>savings</mat-icon>
</div>
<div>
<h3 class="dal-card__title">{{ 'labels.inputs.Transfer to savings account' | translate }}</h3>
<span class="dal-card__sub">Maturity proceeds will be credited to this account</span>
<h3 class="dal-card__title">{{ transferAccountCardTitle }}</h3>
<span class="dal-card__sub">{{ transferAccountCardSubtitle }}</span>
</div>
</div>
</div>
@@ -69,7 +81,7 @@
</div>
} @else {
<mat-form-field appearance="outline" class="flex-98">
<mat-label>{{ 'labels.inputs.Transfer to savings account' | translate }}</mat-label>
<mat-label>{{ transferAccountCardTitle }}</mat-label>
<mat-select formControlName="transferToSavingsId" required>
@for (account of savingsAccountsData; track account) {
<mat-option [value]="account.id">
@@ -77,13 +89,15 @@
</mat-option>
}
</mat-select>
<mat-error>Transfer to savings account is required</mat-error>
<mat-error>{{ transferAccountCardTitle }} is required</mat-error>
</mat-form-field>
}
</div>
</div>
}
<h4 class="mat-h4 flex-98">{{ 'labels.heading.For Pre-mature closure' | translate }}</h4>
<mat-checkbox class="flex-98 margin-v" labelPosition="before" formControlName="preClosurePenalApplicable">
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}
</mat-checkbox>
@@ -11,6 +11,7 @@ import { Component, Input, OnChanges, SimpleChanges, inject } from '@angular/cor
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatIcon } from '@angular/material/icon';
import { MatIconButton } from '@angular/material/button';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -25,7 +26,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox,
MatIcon
MatIcon,
MatIconButton
]
})
export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges {
@@ -53,9 +55,12 @@ export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges
preClosurePenalInterestOnTypeId: ['']
});
this.closureSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => {
// 200 = Transfer to Savings -- Withdraw Deposit (100) needs no target account, and both
// Reinvest options (300/400) open a brand-new FD instead, so they ignore this field too.
if (value === 200) {
// 200 = Transfer to Savings -- the whole matured balance goes here. 400 = Reinvest Principal
// Only -- the principal rolls into a new FD, but the leftover interest still needs the same
// kind of target so it isn't left stranded on the closed account (a real gap the account used
// to silently hit before this field also asked for it here). Reinvest Principal + Interest
// (300) rolls the whole balance into the same account, so it never needs a target at all.
if (value === 200 || value === 400) {
this.closureSettingsForm.addControl(
'transferToSavingsId',
new UntypedFormControl(this.linkedSavingsAccountId ?? '', Validators.required)
@@ -91,15 +96,48 @@ export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges
});
}
}
// Editing an existing account: its own already-saved Maturity Instructions choice lives on
// `fixedDepositsAccountTemplate` (the real account+template GET, FixedDepositAccountData's
// `onAccountClosure`) -- NOT on `fixedDepositsAccountProductTemplate`, which is only the blank
// per-product default template refetched from the Product dropdown's own productId (via
// getFixedDepositsAccountTemplate(clientId, productId)) and carries no account-specific data at
// all. This was never read from the right place before, so Edit always opened onto a blank
// picker even for an account that really does have an instruction saved. `.dirty` guards it the
// same way the penal-interest fields above do, so a maker's own in-progress edit here is never
// silently overwritten if this input fires again mid-edit.
if (this.fixedDepositsAccountTemplate && !this.closureSettingsForm.get('maturityInstructionId').dirty) {
this.closureSettingsForm
.get('maturityInstructionId')
?.setValue(this.fixedDepositsAccountTemplate.onAccountClosure?.id ?? '');
const transferControl = this.closureSettingsForm.get('transferToSavingsId');
if (transferControl) {
transferControl.setValue(
this.fixedDepositsAccountTemplate.transferToSavingsId ?? this.linkedSavingsAccountId ?? ''
);
}
}
}
/** The linked-savings account for the transfer-to-savings maturity instruction, once picked. */
/** The linked-savings account for the transfer-to-savings / reinvest-principal-only-interest
* maturity instruction, once picked. */
get selectedTransferAccount(): any {
const id = this.closureSettingsForm.value.transferToSavingsId;
if (!id) return null;
return this.savingsAccountsData.find((account: any) => account.id === id) ?? null;
}
get transferAccountCardTitle(): string {
return this.closureSettingsForm.value.maturityInstructionId === 400
? 'Interest payout account'
: 'Transfer to savings account';
}
get transferAccountCardSubtitle(): string {
return this.closureSettingsForm.value.maturityInstructionId === 400
? 'The principal renews into a new FD; interest earned is paid out to this account.'
: 'Maturity proceeds will be credited to this account.';
}
clearTransferAccount(): void {
this.closureSettingsForm.get('transferToSavingsId')?.setValue('');
}
@@ -8,11 +8,24 @@
<div class="pmt">
<div class="pmt__header">
<span class="pmt__header-ico"><fa-icon icon="sync-alt"></fa-icon></span>
<div class="pmt__header-text">
<h2 class="pmt__title">{{ 'labels.heading.Payment' | translate }}</h2>
<p class="pmt__subtitle">Process this Fixed Deposit now -- works whether it's Active or Matured.</p>
<div class="pmt__header-left">
<span class="pmt__header-ico"><fa-icon icon="sync-alt"></fa-icon></span>
<div class="pmt__header-text">
<h2 class="pmt__title">
{{ isDashboardRenewalMode ? 'Renew Fixed Deposit' : ('labels.heading.Payment' | translate) }}
</h2>
<p class="pmt__subtitle">
{{
isDashboardRenewalMode
? 'Renew ' + (dashboardRenewalRow?.accountNo ?? 'this matured FD') + '.'
: "Process this Fixed Deposit now -- works whether it's Active or Matured."
}}
</p>
</div>
</div>
<button type="button" class="pmt__header-close" mat-dialog-close [disabled]="submitting" title="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<div mat-dialog-content class="pmt__content">
@@ -27,37 +40,46 @@
</div>
} @else {
<form [formGroup]="paymentForm">
@if (isDashboardRenewalMode) {
<p class="pmt__hint">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
{{ dashboardRenewalTimingText }}
</p>
}
@if (reinvestCap !== null && reinvestCap !== undefined) {
<div class="pmt__stat">
<span class="pmt__stat-label">
{{
remainderIsApproximate
? 'Amount available on the selected date'
: ('labels.inputs.Maturity Amount' | translate)
isDashboardRenewalMode || !remainderIsApproximate
? ('labels.inputs.Maturity Amount' | translate)
: 'Amount available on the selected date'
}}
</span>
<span class="pmt__stat-value">{{ reinvestCap | formatNumber }}</span>
</div>
}
<mat-form-field appearance="outline" class="pmt__date" (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On Date' | translate }}</mat-label>
<input
matInput
[max]="maxDate"
[matDatepicker]="closedOnDatePicker"
required
formControlName="closedOnDate"
/>
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closedOnDatePicker></mat-datepicker>
@if (paymentForm.controls.closedOnDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
@if (!isDashboardRenewalMode) {
<mat-form-field appearance="outline" class="pmt__date" (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On Date' | translate }}</mat-label>
<input
matInput
[max]="maxDate"
[matDatepicker]="closedOnDatePicker"
required
formControlName="closedOnDate"
/>
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closedOnDatePicker></mat-datepicker>
@if (paymentForm.controls.closedOnDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
}
<div class="pmt__section-label" id="pmt-action-label">{{ 'labels.inputs.Action' | translate }}</div>
<div class="pmt__options" role="radiogroup" aria-labelledby="pmt-action-label">
@@ -162,10 +184,12 @@
</mat-form-field>
}
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
</mat-form-field>
@if (!isDashboardRenewalMode) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
</mat-form-field>
}
@if (submitError) {
<p class="pmt__error" role="alert">
@@ -178,14 +202,25 @@
</div>
<div mat-dialog-actions align="end" class="pmt__actions">
<button mat-button mat-dialog-close [disabled]="submitting">{{ 'labels.buttons.Cancel' | translate }}</button>
<button mat-button mat-dialog-close [disabled]="submitting" class="pmt__btn-cancel">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button
mat-raised-button
color="primary"
class="pmt__btn-submit"
[disabled]="!paymentForm?.valid || submitting || loadFailed"
(click)="submit()"
>
{{ submitting ? ('labels.buttons.Processing' | translate) : ('labels.buttons.Confirm' | translate) }}
{{
submitting
? isDashboardRenewalMode
? 'Renewing...'
: ('labels.buttons.Processing' | translate)
: isDashboardRenewalMode
? 'Confirm & Renew'
: ('labels.buttons.Confirm' | translate)
}}
</button>
</div>
</div>
@@ -6,291 +6,404 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$navy: #0d1b2a;
$slate: #475569;
$muted: #64748b;
$border: #e2e8f0;
$soft: #f8fafc;
$blue: #1e40af;
$blue-soft: #eff6ff;
$green: #166534;
$green-soft: #f0fdf4;
/**
* Payment / Renewal Dialog Styles
* CBSNexora Core Banking System
*/
.pmt {
display: flex;
flex-direction: column;
min-height: 320px;
max-height: 85vh;
height: 100%;
max-height: 92vh;
box-sizing: border-box;
font-family:
'DM Sans',
system-ui,
-apple-system,
sans-serif;
color: #0f172a;
border-radius: 16px;
overflow: hidden;
margin: -24px;
box-shadow: 0 24px 48px -12px rgb(15 23 42 / 25%);
}
.pmt__header {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 22px 28px 18px;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 55%, #4338ca 100%);
color: #fff;
flex: 0 0 auto;
}
.pmt__header-ico {
width: 40px;
height: 40px;
min-width: 40px;
border-radius: 10px;
background: rgb(255 255 255 / 15%);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
justify-content: space-between;
gap: 16px;
padding: 26px 30px;
background: linear-gradient(135deg, #1e3a8a 0%, #1d4ed8 55%, #2563eb 100%);
color: #fff;
flex-shrink: 0;
position: relative;
overflow: hidden;
&::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at 88% -20%, rgb(255 255 255 / 22%), transparent 55%);
pointer-events: none;
}
&-left {
display: flex;
align-items: center;
gap: 16px;
position: relative;
z-index: 1;
}
&-ico {
width: 48px;
height: 48px;
border-radius: 14px;
background: rgb(255 255 255 / 16%);
border: 1px solid rgb(255 255 255 / 25%);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: #fff;
flex-shrink: 0;
}
&-text {
display: flex;
flex-direction: column;
}
&-close {
position: relative;
z-index: 1;
background: rgb(255 255 255 / 14%);
border: none;
color: #fff;
width: 34px;
height: 34px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
&:hover:not(:disabled) {
background: rgb(255 255 255 / 28%);
transform: rotate(90deg);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
.pmt__title {
margin: 0;
font-size: 19px;
font-weight: 700;
font-size: 20px;
font-weight: 800;
color: #fff;
line-height: 1.25;
letter-spacing: -0.01em;
}
.pmt__subtitle {
margin: 4px 0 0;
font-size: 12.5px;
color: rgb(255 255 255 / 82%);
margin: 5px 0 0;
font-size: 13px;
color: rgb(255 255 255 / 88%);
line-height: 1.4;
}
.pmt__content {
padding: 22px 28px 8px !important;
padding: 26px 30px !important;
overflow-y: auto;
flex: 1 1 auto;
background: #fbfcfe;
mat-form-field {
width: 100%;
}
}
.pmt__loading {
display: flex;
justify-content: center;
align-items: center;
min-height: 260px;
min-height: 240px;
}
.pmt__stat {
display: flex;
flex-direction: column;
gap: 2px;
background: $blue-soft;
border: 1px solid #dbeafe;
border-radius: 10px;
padding: 12px 16px;
gap: 4px;
background: linear-gradient(135deg, #eff6ff 0%, #e0e9ff 100%);
border: 1px solid #bfdbfe;
border-radius: 14px;
padding: 18px 22px;
margin-bottom: 18px;
}
.pmt__stat-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $blue;
}
&-label {
font-size: 11.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #1d4ed8;
}
.pmt__stat-value {
font-size: 22px;
font-weight: 800;
color: $navy;
font-variant-numeric: tabular-nums;
}
.pmt__date {
width: 100%;
&-value {
font-family: 'JetBrains Mono', 'DM Sans', monospace;
font-size: 28px;
font-weight: 800;
color: #0f172a;
letter-spacing: -0.01em;
}
}
.pmt__section-label {
font-size: 11.5px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $slate;
margin: 4px 0 10px;
letter-spacing: 0.06em;
color: #475569;
margin: 10px 0 12px;
}
/* Action Options Grid - 2 Equal Columns, comfortably wide now that the dialog itself is wider */
.pmt__options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 10px;
margin-bottom: 6px;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
margin-bottom: 18px;
@media (width <= 560px) {
grid-template-columns: 1fr;
}
}
.pmt__option {
display: flex;
align-items: flex-start;
gap: 10px;
gap: 14px;
text-align: left;
background: #fff;
border: 1.5px solid $border;
border-radius: 10px;
padding: 12px 14px;
border: 1.5px solid #e2e8f0;
border-radius: 14px;
padding: 16px;
cursor: pointer;
font: inherit;
transition:
border-color 0.15s,
background 0.15s,
box-shadow 0.15s;
}
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
.pmt__option:hover {
border-color: #93c5fd;
background: $blue-soft;
}
&:hover {
border-color: #93c5fd;
background: #f8fafc;
box-shadow: 0 6px 16px rgb(15 23 42 / 6%);
transform: translateY(-1px);
}
.pmt__option--active {
border-color: $blue;
background: $blue-soft;
box-shadow: 0 0 0 1px $blue;
}
&--active {
border-color: #2563eb;
background: #eff6ff;
box-shadow: 0 0 0 1.5px #2563eb;
.pmt__option-ico {
width: 30px;
height: 30px;
min-width: 30px;
border-radius: 8px;
background: $soft;
color: $blue;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
}
.pmt__option-ico {
background: #2563eb;
color: #fff;
border-color: #2563eb;
}
}
.pmt__option--active .pmt__option-ico {
background: $blue;
color: #fff;
}
&-ico {
width: 38px;
height: 38px;
min-width: 38px;
border-radius: 10px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
color: #2563eb;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
flex-shrink: 0;
transition: all 0.2s ease;
}
.pmt__option-text {
display: flex;
flex-direction: column;
gap: 2px;
}
// Distinct accent per option while unselected, purely presentational -- helps the four cards read
// apart at a glance. Selecting a card always fills its icon blue (see &--active above), matching the
// blue selection border/check regardless of which option it is, rather than mixing selection-blue
// with the option's own accent.
&:not(.pmt__option--active):nth-child(2) .pmt__option-ico {
color: #7c3aed;
}
.pmt__option-title {
font-size: 13px;
font-weight: 700;
color: $navy;
}
&:not(.pmt__option--active):nth-child(3) .pmt__option-ico {
color: #0d9488;
}
.pmt__option-desc {
font-size: 11px;
color: $muted;
line-height: 1.35;
}
&:not(.pmt__option--active):nth-child(4) .pmt__option-ico {
color: #d97706;
}
.pmt__option:focus-visible {
outline: 2px solid $blue;
outline-offset: 2px;
}
&-text {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.pmt__option-check {
margin-left: auto;
color: $blue;
font-size: 13px;
&-title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
line-height: 1.3;
}
&-desc {
font-size: 12px;
color: #64748b;
line-height: 1.4;
}
&-check {
color: #2563eb;
font-size: 17px;
position: absolute;
top: 14px;
right: 14px;
}
}
.pmt__option-error {
color: #b91c1c;
font-size: 12px;
margin: 4px 0 14px;
}
.pmt__error {
display: flex;
align-items: flex-start;
gap: 4px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
color: #b91c1c;
font-size: 12.5px;
line-height: 1.45;
padding: 10px 14px;
margin: 4px 0 8px;
}
.pmt__hint {
display: flex;
align-items: flex-start;
gap: 4px;
background: $soft;
border: 1px solid $border;
border-radius: 8px;
color: $slate;
font-size: 12px;
line-height: 1.45;
padding: 9px 13px;
margin: 0 0 10px;
}
.pmt__hint--warn {
background: #fffbeb;
border-color: #fde68a;
color: #92400e;
font-weight: 600;
margin: -10px 0 14px;
}
.pmt__split {
display: flex;
align-items: center;
gap: 12px;
background: $soft;
gap: 16px;
background: #f8fafc;
border: 1px dashed #cbd5e1;
border-radius: 10px;
padding: 14px 16px 0;
margin: 4px 0 8px;
border-radius: 14px;
padding: 18px;
margin: 10px 0 18px;
flex-wrap: wrap;
&-input {
flex: 1 1 220px;
}
&-arrow {
color: #94a3b8;
font-size: 17px;
}
&-remainder {
display: flex;
flex-direction: column;
gap: 3px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 10px;
padding: 12px 16px;
flex: 1 1 220px;
&-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #166534;
}
&-value {
font-family: 'JetBrains Mono', 'DM Sans', monospace;
font-size: 19px;
font-weight: 800;
color: #166534;
}
}
}
.pmt__split-input {
flex: 1 1 180px;
min-width: 160px;
}
.pmt__split-arrow {
color: $muted;
font-size: 14px;
margin-bottom: 20px;
}
.pmt__split-remainder {
.pmt__hint {
display: flex;
flex-direction: column;
gap: 2px;
background: $green-soft;
border: 1px solid #bbf7d0;
border-radius: 8px;
padding: 8px 14px;
margin-bottom: 14px;
flex: 1 1 180px;
min-width: 160px;
align-items: flex-start;
gap: 10px;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-left: 4px solid #0284c7;
border-radius: 10px;
color: #0369a1;
font-size: 13px;
line-height: 1.5;
padding: 12px 16px;
margin: 0 0 18px;
&--warn {
background: #fffbeb;
border-color: #fde68a;
border-left-color: #d97706;
color: #b45309;
}
}
.pmt__split-remainder-label {
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $green;
}
.pmt__split-remainder-value {
font-size: 16px;
font-weight: 800;
color: $green;
font-variant-numeric: tabular-nums;
}
.pmt__content mat-form-field {
width: 100%;
.pmt__error {
display: flex;
align-items: flex-start;
gap: 10px;
background: #fef2f2;
border: 1px solid #fecaca;
border-left: 4px solid #ef4444;
border-radius: 10px;
color: #b91c1c;
font-size: 13px;
padding: 12px 16px;
margin: 10px 0;
}
.pmt__actions {
padding: 14px 28px 20px !important;
border-top: 1px solid $border;
flex: 0 0 auto;
padding: 18px 30px !important;
border-top: 1px solid #e2e8f0;
background: #fff;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 14px;
flex-shrink: 0;
.pmt__btn-cancel {
border-radius: 10px;
font-weight: 600;
color: #475569;
}
.pmt__btn-submit {
border-radius: 10px;
font-weight: 700;
padding: 0 26px;
height: 42px;
background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%);
color: #fff;
box-shadow: 0 8px 20px rgb(37 99 235 / 30%);
transition: all 0.2s ease;
&:hover:not(:disabled) {
box-shadow: 0 10px 24px rgb(37 99 235 / 40%);
transform: translateY(-1px);
}
&:disabled {
background: #cbd5e1;
box-shadow: none;
color: #64748b;
}
}
}
@@ -27,12 +27,22 @@ import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FdMaturityRow } from 'app/fd-dashboard/fd-dashboard.compute';
/**
* "Payment" dialog -- an always-available alternative to Premature Close (Active-only, no reinvest) and
* Close (Matured-only, no partial option). Works on an FD in either state and offers a fourth choice,
* Partial Reinvest, alongside the ones the backend's `DepositAccountOnClosureType` already had: the
* entered amount renews on this same account, the rest transfers to a savings account picked here.
*
* Doubles as the FD Dashboard's Renew action when opened with `data.dashboardRenewal` set: same form,
* same full set of closure types (nothing removed -- Transfer to Savings and Partial Reinvest work from
* the Dashboard too, not just the two Reinvest options). The only two differences in that mode: the
* Closed On Date is computed from the FD's grace window instead of picked freely (backdated to the
* original maturity date within grace, today once it's passed -- the picker is hidden, not just
* defaulted, so it can't accidentally be moved off that date), and submit() first calls
* `creditGapInterestIfPastGrace` when past grace, crediting the gap-period interest (at the linked
* account's rate) onto the FD before the normal `payment` command runs and reads its balance.
*/
@Component({
selector: 'mifosx-payment-dialog',
@@ -76,7 +86,13 @@ export class PaymentDialogComponent implements OnInit {
};
dialogRef = inject<MatDialogRef<PaymentDialogComponent>>(MatDialogRef);
data: { accountId: number | string; accountBalance?: number; maturityDate?: any } = inject(MAT_DIALOG_DATA);
data: {
accountId: number | string;
accountBalance?: number;
maturityDate?: any;
/** Present only when opened from the FD Dashboard's Renew action. */
dashboardRenewal?: { row: FdMaturityRow };
} = inject(MAT_DIALOG_DATA);
private formBuilder = inject(UntypedFormBuilder);
private fixedDepositsService = inject(FixedDepositsService);
private settingsService = inject(SettingsService);
@@ -93,6 +109,10 @@ export class PaymentDialogComponent implements OnInit {
submitError: string = null;
maxDate: Date;
/** Whether gap-interest still needs crediting before this submits -- only ever true once, right
* after open; cleared as soon as `submit()` has credited it, so a retried submit doesn't double-post. */
private gapInterestPending = false;
ngOnInit(): void {
this.maxDate = this.settingsService.businessDate;
this.paymentForm = this.formBuilder.group({
@@ -106,6 +126,18 @@ export class PaymentDialogComponent implements OnInit {
],
note: ['']
});
const dashboardRow = this.dashboardRenewalRow;
if (dashboardRow) {
// Computed from the grace window, never picked -- the date field stays in the form (the generic
// submit path and reinvestCap/processingOnOrAfterMaturity both key off it) but its picker is
// hidden in this mode (see the template), so it can't be moved off this value.
this.paymentForm
.get('closedOnDate')
.setValue(dashboardRow.withinGraceWindow ? new Date(dashboardRow.maturityDate) : this.maxDate);
this.gapInterestPending = !dashboardRow.withinGraceWindow;
}
this.paymentForm.get('onAccountClosureId').valueChanges.subscribe((id: number) => this.onClosureTypeChange(id));
// The cap depends on whether the chosen date is on or after maturity, so a date change can make an
// already-entered amount valid or invalid. Without this the amount keeps its verdict from the old date.
@@ -136,6 +168,25 @@ export class PaymentDialogComponent implements OnInit {
});
}
get isDashboardRenewalMode(): boolean {
return !!this.data.dashboardRenewal;
}
get dashboardRenewalRow(): FdMaturityRow | null {
return this.data.dashboardRenewal?.row ?? null;
}
/** What clicking Confirm is actually about to do -- shown above the (hidden-picker) date in this
* mode, since the date itself is no longer visible to explain it. */
get dashboardRenewalTimingText(): string {
const row = this.dashboardRenewalRow;
if (!row) return '';
return row.withinGraceWindow
? `Backdated to its original maturity date (${row.maturityDate}), at the currently active interest rate.`
: `This FD's grace period has passed -- gap-period interest (for the days since maturity) will be ` +
`credited at the linked account's rate first, then this renews from today.`;
}
/**
* The amount being split, and the bound the entered partial amount is checked against.
* <p>
@@ -259,6 +310,31 @@ export class PaymentDialogComponent implements OnInit {
}
submit(): void {
if (this.gapInterestPending) {
this.submitting = true;
this.submitError = null;
this.fixedDepositsService.creditGapInterestIfPastGrace(this.data.accountId).subscribe({
next: () => {
this.gapInterestPending = false;
this.submitGenericPayment();
},
error: (error: any) => {
this.submitting = false;
this.submitError =
error?.error?.defaultUserMessage ??
error?.error?.errors?.[0]?.defaultUserMessage ??
'Could not credit gap-period interest. Please review the details and try again.';
}
});
return;
}
this.submitGenericPayment();
}
/** The same `command=payment` submission every non-Dashboard call to this dialog already used --
* Dashboard-renewal mode reaches it too, once any gap-interest pre-step above has cleared, so every
* closure type behaves identically regardless of where the dialog was opened from. */
private submitGenericPayment(): void {
const formValue = { ...this.paymentForm.value };
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
@@ -276,7 +276,7 @@ export class FixedDepositAccountViewComponent implements OnInit {
*/
private openPaymentDialog(): void {
const paymentDialogRef = this.dialog.open(PaymentDialogComponent, {
width: '640px',
width: '720px',
maxWidth: '95vw',
data: {
accountId: this.fixedDepositsAccountData.id,
@@ -57,11 +57,12 @@ export class FixedDepositsButtonsConfiguration {
];
break;
case 'Matured':
// 'Close' (the generic Fineract apply-maturity-instructions action) is deliberately left out
// here -- what happens to a matured FD is now handled either automatically by the nightly job
// (an account opened with an explicit instruction) or manually via the FD Dashboard's Renew
// action (an account left without one, backdated within grace / gap-interest after). Closing
// from here would go around both of those paths.
this.buttonsArray = [
{
name: 'Close',
icon: 'fa fa-arrow-right'
},
{
name: 'Calculate Interest',
icon: 'fa fa-table'
@@ -154,6 +154,26 @@ export class FixedDepositsService {
return this.http.get(`/fixeddepositaccounts/calculate-fd-interest`, { params: httpParams });
}
/**
* FD Dashboard's "Renew" action on a matured FD whose product has a renewal grace period
* configured: backdated to the original maturity date within the grace window, or from today
* with a gap-period interest credit (at the linked Savings account's rate) once it has passed.
* @param accountId Fixed Deposit Account Id
* @returns {Observable<any>} { renewedAccountId, maturityDate, maturityAmount, backdated, gapInterestAmount? }
*/
/** `reinvestType` is the staff's choice made at renewal time: 300 = Reinvest Principal + Interest,
* 400 = Reinvest Principal Only (rest of the balance goes to the FD's linked Savings account). */
renewFixedDepositAccount(accountId: string | number, reinvestType: 300 | 400): Observable<any> {
return this.http.post(`/fixeddepositaccounts/${accountId}/renew`, { reinvestType });
}
/** Pre-step the Payment dialog calls, from the FD Dashboard, before submitting the generic `payment`
* command -- credits gap-period interest (at the linked account's rate) if the product's renewal
* grace window has passed; a no-op (`gapInterestAmount: 0`) if still within it. */
creditGapInterestIfPastGrace(accountId: string | number): Observable<any> {
return this.http.post(`/fixeddepositaccounts/${accountId}/credit-gap-interest`, {});
}
/**
* @param fixedDepositAccount Fixed Deposit Account
* @returns {Observable<any>}
@@ -0,0 +1,29 @@
/**
* 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 { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { FdDashboardComponent } from './fd-dashboard.component';
const routes: Routes = [
Route.withShell([
{
path: 'fd-dashboard',
component: FdDashboardComponent,
data: { title: 'FD Dashboard', breadcrumb: 'FD Dashboard' }
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class FdDashboardRoutingModule {}
@@ -0,0 +1,240 @@
<!--
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="fdd-container">
<!-- Compact Light Header -->
<div class="fdd-header">
<div class="fdd-header__left">
<div class="fdd-header__icon">
<fa-icon icon="piggy-bank"></fa-icon>
</div>
<div>
<div class="fdd-header__title-row">
<h2 class="fdd-header__title">FD Dashboard & Maturity Ladder</h2>
<span class="fdd-header__badge">Core Banking</span>
</div>
<p class="fdd-header__sub">
Active Fixed Deposits positioned against maturity dates. Press <kbd>ESC</kbd> to toggle header & footer.
</p>
</div>
</div>
<div class="fdd-header__right">
<button class="fdd-btn fdd-btn--slim" (click)="load()" [disabled]="loading" title="Refresh maturity ladder data">
<fa-icon icon="sync-alt" [class.fa-spin]="loading"></fa-icon>
<span>{{ loading ? 'Syncing...' : 'Reload' }}</span>
</button>
</div>
</div>
<!-- Alerts -->
@if (errorMessage) {
<div class="fdd-alert fdd-alert--danger">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>{{ errorMessage }}</span>
</div>
}
<!-- Compact Stat Cards Grid -->
<div class="fdd-stats-grid">
@for (key of stageKeys; track key) {
<button
[class]="'fdd-stat-card fdd-stat-card--' + stageMeta[key].tone"
[class.fdd-stat-card--active]="stageFilter === key"
(click)="toggleStage(key)"
>
<div class="fdd-stat-card__header">
<span class="fdd-stat-card__label">{{ stageMeta[key].label }}</span>
<div class="fdd-stat-card__icon">
@switch (key) {
@case ('MATURED') {
<fa-icon icon="exclamation-circle"></fa-icon>
}
@case ('DUE_SOON') {
<fa-icon icon="clock"></fa-icon>
}
@case ('UPCOMING') {
<fa-icon icon="calendar-alt"></fa-icon>
}
@default {
<fa-icon icon="check-circle"></fa-icon>
}
}
</div>
</div>
<div class="fdd-stat-card__val">
@switch (key) {
@case ('MATURED') {
{{ summary.matured }}
}
@case ('DUE_SOON') {
{{ summary.dueSoon }}
}
@case ('UPCOMING') {
{{ summary.upcoming }}
}
@default {
{{ summary.onTrack }}
}
}
</div>
<div class="fdd-stat-card__sub">{{ stageMeta[key].summary }}</div>
</button>
}
</div>
<!-- Compact Filter & Search Toolbar -->
<div class="fdd-toolbar">
<div class="fdd-search-box">
<fa-icon icon="search" class="fdd-search-box__icon"></fa-icon>
<input
class="fdd-search-box__input"
type="text"
placeholder="Search account no. or client name..."
[(ngModel)]="search"
/>
@if (search) {
<button class="fdd-search-box__clear" (click)="search = ''" title="Clear search">
<fa-icon icon="times"></fa-icon>
</button>
}
</div>
@if (stageFilter || search) {
<button class="fdd-btn fdd-btn--reset fdd-btn--slim" (click)="resetFilters()">
<fa-icon icon="undo"></fa-icon> Reset
</button>
}
<div class="fdd-toolbar__spacer"></div>
<div class="fdd-count-chip">
<fa-icon icon="layer-group"></fa-icon>
<span
><strong>{{ visibleRows.length }}</strong> of {{ summary.total }} active FDs</span
>
</div>
</div>
<!-- Worklist Table / Empty State -->
@if (loading) {
<div class="fdd-card fdd-state-card">
<fa-icon icon="spinner" [class.fa-spin]="true" class="fdd-spinner"></fa-icon>
<p>Loading FD maturity list...</p>
</div>
} @else if (visibleRows.length === 0) {
<div class="fdd-card fdd-state-card">
<fa-icon icon="inbox" class="fdd-state-icon"></fa-icon>
<p class="fdd-state-msg">
@if (!stageFilter && summary.total > 0) {
No FDs are matured or maturing within {{ policy.upcomingDays }} days. Click "On Track" to see all active FDs.
} @else {
No FDs match these filters.
}
</p>
@if (stageFilter || search) {
<button class="fdd-btn fdd-btn--primary fdd-btn--slim" (click)="resetFilters()">
<fa-icon icon="undo"></fa-icon> Clear Filters
</button>
}
</div>
} @else {
<div class="fdd-table-card">
<div class="fdd-table-wrapper">
<table class="fdd-table">
<thead>
<tr>
<th>Account</th>
<th>Client</th>
<th>Product</th>
<th>Maturity Date</th>
<th class="text-right">Deposit Amount</th>
<th class="text-right">Maturity Amount</th>
<th>Status</th>
<th class="text-center">Action</th>
</tr>
</thead>
<tbody>
@for (r of visibleRows; track r.id) {
<tr class="fdd-row" (click)="openAccount(r)">
<td>
<span class="fdd-acc-num">{{ r.accountNo || '—' }}</span>
</td>
<td>
<div class="fdd-client-cell">
<span class="fdd-client-name">{{ r.clientName }}</span>
</div>
</td>
<td>
<span class="fdd-product-tag">{{ r.productName }}</span>
</td>
<td>
<div class="fdd-date-cell">
<span class="fdd-date-val">{{ r.maturityDate }}</span>
<span class="fdd-date-sub" [class.fdd-date-sub--overdue]="r.daysOverdue > 0">
{{ maturityText(r) }}
</span>
</div>
</td>
<td class="text-right">
<span class="fdd-amount fdd-amount--deposit">
{{ r.depositAmount != null ? '₹' + (r.depositAmount | number: '1.2-2') : '—' }}
</span>
</td>
<td class="text-right">
<span class="fdd-amount fdd-amount--maturity">
{{ r.maturityAmount != null ? '₹' + (r.maturityAmount | number: '1.2-2') : '—' }}
</span>
</td>
<td>
<div class="fdd-status-cell">
<span [class]="stageClass(r.stage)">
{{ r.meta.label }}
</span>
@if (r.renewable) {
<div
class="fdd-grace-sub"
[class.fdd-grace-sub--warning]="r.renewalGracePeriodDays > 0 && !r.withinGraceWindow"
>
{{ graceStatusText(r) }}
</div>
} @else if (r.configuredInstructionLabel) {
<div class="fdd-grace-sub">Configured: {{ r.configuredInstructionLabel }} (auto-processes)</div>
}
</div>
</td>
<td class="fdd-act-cell text-center" (click)="$event.stopPropagation()">
@if (r.renewable) {
<button class="fdd-btn fdd-btn--action" [disabled]="renewing === r.id" (click)="openRenewDialog(r)">
@if (renewing === r.id) {
<fa-icon icon="spinner" [class.fa-spin]="true"></fa-icon> Renewing...
} @else {
<fa-icon icon="sync-alt"></fa-icon> Renew
}
</button>
} @else {
<button class="fdd-btn-icon" (click)="openAccount(r)" title="View Account Details">
<fa-icon icon="chevron-right"></fa-icon>
</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
@@ -0,0 +1,578 @@
/**
* 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/.
*/
/**
* Ultra-Compact Light FD Dashboard & Maturity Ladder Styles
* CBSNexora Core Banking Design System
*/
:host {
display: block;
font-family:
'DM Sans',
system-ui,
-apple-system,
sans-serif;
color: #0f172a;
background: #f8fafc;
min-height: 100vh;
padding: 12px 16px;
box-sizing: border-box;
}
.fdd-container {
max-width: 1400px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 12px;
}
/* ── Light Compact Header ────────────────────────────────────────────────── */
.fdd-header {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
box-shadow: 0 1px 3px rgb(0 0 0 / 4%);
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
width: 36px;
height: 36px;
border-radius: 8px;
background: #eff6ff;
color: #2563eb;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
&__title-row {
display: flex;
align-items: center;
gap: 8px;
}
&__title {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
&__badge {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 2px 6px;
border-radius: 4px;
background: #e0f2fe;
color: #0369a1;
}
&__sub {
margin: 2px 0 0;
font-size: 11.5px;
color: #64748b;
kbd {
background: #f1f5f9;
border: 1px solid #cbd5e1;
border-radius: 3px;
padding: 1px 4px;
font-family: inherit;
font-size: 10.5px;
font-weight: 700;
color: #334155;
}
}
}
/* ── Alerts ─────────────────────────────────────────────────────────────── */
.fdd-alert {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
&--danger {
background: #fef2f2;
border: 1px solid #fca5a5;
color: #dc2626;
}
&--warning {
background: #fffbeb;
border: 1px solid #fcd34d;
color: #d97706;
}
}
/* ── Compact Stat Cards Grid ────────────────────────────────────────────── */
.fdd-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
}
.fdd-stat-card {
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #cbd5e1;
border-radius: 10px;
padding: 10px 14px;
text-align: left;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
border-color: #cbd5e1;
box-shadow: 0 4px 12px rgb(15 23 42 / 6%);
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
&__label {
font-size: 12px;
font-weight: 700;
color: #334155;
}
&__icon {
font-size: 14px;
}
&__val {
font-size: 20px;
font-weight: 800;
line-height: 1.1;
color: #0f172a;
margin-bottom: 2px;
}
&__sub {
font-size: 10.5px;
color: #64748b;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Tone styling */
&--danger {
border-left-color: #ef4444;
.fdd-stat-card__icon {
color: #ef4444;
}
&.fdd-stat-card--active {
background: #fef2f2;
border-color: #fca5a5;
}
}
&--warn {
border-left-color: #f59e0b;
.fdd-stat-card__icon {
color: #f59e0b;
}
&.fdd-stat-card--active {
background: #fffbeb;
border-color: #fcd34d;
}
}
&--info {
border-left-color: #3b82f6;
.fdd-stat-card__icon {
color: #3b82f6;
}
&.fdd-stat-card--active {
background: #eff6ff;
border-color: #93c5fd;
}
}
&--neutral {
border-left-color: #64748b;
.fdd-stat-card__icon {
color: #64748b;
}
&.fdd-stat-card--active {
background: #f1f5f9;
border-color: #cbd5e1;
}
}
}
/* ── Compact Filter Toolbar ─────────────────────────────────────────────── */
.fdd-toolbar {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 6px 10px;
display: flex;
align-items: center;
gap: 10px;
box-shadow: 0 1px 2px rgb(0 0 0 / 2%);
&__spacer {
flex: 1;
}
}
.fdd-search-box {
position: relative;
display: flex;
align-items: center;
width: 300px;
&__icon {
position: absolute;
left: 10px;
color: #94a3b8;
font-size: 12px;
pointer-events: none;
}
&__input {
width: 100%;
padding: 5px 30px 5px 28px;
border: 1px solid #cbd5e1;
border-radius: 6px;
font-size: 12px;
font-family: inherit;
color: #0f172a;
background: #fff;
&:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 2px rgb(37 99 235 / 15%);
}
}
&__clear {
position: absolute;
right: 6px;
border: none;
background: transparent;
color: #94a3b8;
cursor: pointer;
padding: 2px 4px;
&:hover {
color: #334155;
}
}
}
.fdd-count-chip {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
color: #64748b;
}
/* ── Buttons ────────────────────────────────────────────────────────────── */
.fdd-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
font-family: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
border: 1px solid #cbd5e1;
background: #fff;
color: #334155;
transition: all 0.15s ease;
&:hover:not(:disabled) {
border-color: #2563eb;
color: #2563eb;
background: #f8fafc;
}
&--slim {
padding: 4px 10px;
font-size: 11.5px;
}
&--primary {
background: #2563eb;
color: #fff;
border-color: #2563eb;
&:hover:not(:disabled) {
background: #1d4ed8;
color: #fff;
}
}
&--action {
background: #2563eb;
color: #fff;
border-color: #2563eb;
padding: 4px 10px;
font-size: 11px;
border-radius: 5px;
&:hover:not(:disabled) {
background: #1d4ed8;
color: #fff;
}
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.fdd-btn-icon {
width: 26px;
height: 26px;
border-radius: 5px;
border: 1px solid #e2e8f0;
background: #fff;
color: #64748b;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: #eff6ff;
color: #2563eb;
border-color: #93c5fd;
}
}
/* ── Compact Table ──────────────────────────────────────────────────────── */
.fdd-table-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 1px 3px rgb(0 0 0 / 3%);
}
.fdd-table-wrapper {
overflow-x: auto;
}
.fdd-table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: 12px;
th {
padding: 8px 10px;
background: #f1f5f9;
color: #475569;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid #e2e8f0;
white-space: nowrap;
}
td {
padding: 7px 10px;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
.text-right {
text-align: right;
}
.text-center {
text-align: center;
}
}
.fdd-row {
cursor: pointer;
&:hover {
background: #f8fafc;
}
&:last-child td {
border-bottom: none;
}
}
.fdd-acc-num {
font-family: 'JetBrains Mono', monospace;
font-size: 11.5px;
font-weight: 600;
color: #0f172a;
}
.fdd-client-name {
font-weight: 600;
color: #0f172a;
}
.fdd-product-tag {
font-size: 11px;
color: #475569;
}
.fdd-date-cell {
display: flex;
flex-direction: column;
gap: 1px;
.fdd-date-val {
font-family: 'JetBrains Mono', monospace;
font-size: 11.5px;
color: #0f172a;
}
.fdd-date-sub {
font-size: 10.5px;
color: #64748b;
&--overdue {
color: #dc2626;
font-weight: 700;
}
}
}
.fdd-amount {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 600;
&--deposit {
color: #334155;
}
&--maturity {
color: #2563eb;
font-weight: 700;
}
}
.fdd-status-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.fdd-stage {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 10.5px;
font-weight: 700;
width: fit-content;
&--info {
background: #eff6ff;
color: #2563eb;
}
&--warn {
background: #fffbeb;
color: #d97706;
}
&--danger {
background: #fef2f2;
color: #dc2626;
}
&--neutral {
background: #f1f5f9;
color: #475569;
}
}
.fdd-grace-sub {
font-size: 10px;
color: #64748b;
&--warning {
color: #dc2626;
font-weight: 600;
}
}
.fdd-card.fdd-state-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 24px;
text-align: center;
font-size: 12px;
color: #64748b;
.fdd-state-icon {
font-size: 24px;
color: #94a3b8;
margin-bottom: 8px;
}
.fdd-spinner {
font-size: 20px;
color: #2563eb;
margin-bottom: 8px;
}
.fdd-state-msg {
margin: 0 0 12px;
}
}
@@ -0,0 +1,273 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
/** Custom Modules */
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
/** Custom Services */
import { AccountsService } from '../accounts/accounts.service';
import { FixedDepositsService } from '../deposits/fixed-deposits/fixed-deposits.service';
import { toIsoDate } from 'app/core/utils/api-date';
/** Dashboard data + computation */
import {
FD_MATURITY_POLICY,
FD_MATURITY_STAGES,
FdMaturityRow,
FdMaturityStage,
FdMaturitySummary,
buildMaturityRow,
maturitySummary,
sortMaturityRows
} from './fd-dashboard.compute';
import { PaymentDialogComponent } from '../deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component';
/**
* FD Dashboard: every active Fixed Deposit positioned against its own maturity date.
*
* Read-only and computed, the same way the re-KYC expiry worklist is: the ladder (Matured / Due
* Soon / Upcoming / On Track) is worked out here from `maturityDate`, not stored anywhere, so this
* screen can never drift out of step with the account -- it always says what today's date implies.
* Data comes from the same `/fixeddepositaccounts` list Accounts > Fixed Deposit already uses;
* nothing new was added on the backend for this.
*/
@Component({
selector: 'mifosx-fd-dashboard',
templateUrl: './fd-dashboard.component.html',
styleUrls: ['./fd-dashboard.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent
]
})
export class FdDashboardComponent implements OnInit {
private accountsService = inject(AccountsService);
private fixedDepositsService = inject(FixedDepositsService);
private router = inject(Router);
private dialog = inject(MatDialog);
loading = false;
errorMessage = '';
rows: FdMaturityRow[] = [];
/** Renew action state -- the dialog itself owns submit-in-progress/error display; this just guards
* against opening a second dialog for the same row while one is already up. */
renewing: number | null = null;
/** Filters */
stageFilter: FdMaturityStage | '' = '';
search = '';
readonly policy = FD_MATURITY_POLICY;
readonly stageMeta = FD_MATURITY_STAGES;
/** ON_TRACK is reachable only by clicking its own tile -- the default view stays on what needs
* attention (matured or maturing soon), matching why this screen exists. */
readonly stageKeys: FdMaturityStage[] = [
'MATURED',
'DUE_SOON',
'UPCOMING',
'ON_TRACK'
];
ngOnInit(): void {
this.load();
}
/**
* Loads every active FD and re-derives the maturity ladder for each.
*
* Calls the dedicated maturity-dashboard endpoint (backend: FixedDepositMaturityDashboardApiResource),
* not the general Accounts > Fixed Deposit list endpoint -- that one's query is wide (six extra
* table joins for audit/currency/tax fields this screen never shows) and, until this feature
* added them, ran over unindexed columns. The dashboard endpoint already filters to ACTIVE FDs
* (deposit_type_enum/status_enum are fixed in its SQL, not a request parameter), so there's
* nothing to re-check client-side here.
*/
load(): void {
this.loading = true;
this.errorMessage = '';
const today = toIsoDate(new Date().toISOString());
this.accountsService.getFixedDepositMaturityDashboard().subscribe({
next: (res: any) => {
const items: any[] = Array.isArray(res) ? res : [];
const mapped = items
.map((a: any) => this.mapAccount(a))
.map((a) => buildMaturityRow(a, today))
.filter((r): r is FdMaturityRow => !!r);
this.rows = sortMaturityRows(mapped);
this.loading = false;
},
error: (err: any) => {
this.rows = [];
this.loading = false;
this.errorMessage =
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not load the FD maturity list.';
}
});
}
/** Maps one raw `/fixeddepositaccounts/maturity-dashboard` list item onto what the ladder needs. */
private mapAccount(a: any): {
id: number;
accountNo: string;
clientId?: number;
clientName?: string;
productName?: string;
savingsProductName?: string;
fdType?: string;
maturityDate: string;
depositAmount?: number;
maturityAmount?: number;
onClosureType?: number;
linkedAccountId?: number;
renewalGracePeriodDays?: number;
} {
return {
id: a?.id,
accountNo: a?.accountNo || '',
clientId: a?.clientId,
clientName: a?.clientName,
productName: a?.productName,
savingsProductName: a?.savingsProductName,
fdType: a?.fdType,
maturityDate: toIsoDate(a?.maturityDate),
depositAmount: a?.depositAmount,
maturityAmount: a?.maturityAmount,
onClosureType: a?.onClosureType,
linkedAccountId: a?.linkedAccountId,
renewalGracePeriodDays: a?.renewalGracePeriodDays
};
}
// ── Filtering ──────────────────────────────────────────────────────────────
get visibleRows(): FdMaturityRow[] {
const needle = this.search.trim().toLowerCase();
const stage = this.stageFilter;
return this.rows.filter((r) => {
if (stage) {
if (r.stage !== stage) {
return false;
}
} else if (r.stage === 'ON_TRACK') {
// No tile selected -- the default view is the worklist, not every active FD.
return false;
}
if (needle) {
const hay = `${r.accountNo} ${r.clientName}`.toLowerCase();
if (!hay.includes(needle)) {
return false;
}
}
return true;
});
}
get summary(): FdMaturitySummary {
return maturitySummary(this.rows);
}
resetFilters(): void {
this.stageFilter = '';
this.search = '';
}
toggleStage(stage: FdMaturityStage): void {
this.stageFilter = this.stageFilter === stage ? '' : stage;
}
// ── Navigation ─────────────────────────────────────────────────────────────
openAccount(row: FdMaturityRow): void {
if (!row.clientId) {
return;
}
this.router.navigate([
'/clients',
row.clientId,
'fixed-deposits-accounts',
row.id,
'general'
]);
}
// ── Renewal ────────────────────────────────────────────────────────────────
/** Renew is a single action on a matured, renewable row -- opens the same Payment dialog an account
* page's own Account Actions menu uses, scoped to just the Reinvest choices, with the closure date
* and gap-interest crediting decided from the grace window rather than picked here (see
* PaymentDialogComponent's `dashboardRenewal` mode). */
openRenewDialog(row: FdMaturityRow): void {
if (!row.renewable || this.renewing != null) {
return;
}
this.renewing = row.id;
this.dialog
.open(PaymentDialogComponent, {
width: '720px',
maxWidth: '95vw',
data: {
accountId: row.id,
accountBalance: row.maturityAmount,
maturityDate: row.maturityDate,
dashboardRenewal: { row }
}
})
.afterClosed()
.subscribe((result: { confirm: boolean } | undefined) => {
this.renewing = null;
if (result?.confirm) {
this.load();
}
});
}
// ── Presentation ───────────────────────────────────────────────────────────
stageClass(stage: FdMaturityStage): string {
return `fdd-stage fdd-stage--${FD_MATURITY_STAGES[stage].tone}`;
}
/** "12 days overdue" / "in 5 days" / "Matures today", as a branch officer would say it. */
maturityText(row: FdMaturityRow): string {
if (row.daysOverdue > 0) {
return `${row.daysOverdue} day${row.daysOverdue === 1 ? '' : 's'} overdue`;
}
if (row.daysRemaining === 0) {
return 'Matures today';
}
return `In ${row.daysRemaining} day${row.daysRemaining === 1 ? '' : 's'}`;
}
/** "Renewable — 6 of 9 grace days left" / "Grace period (9d) passed 3 days ago" / "Renewable" (no
* grace configured) -- the sub-label under a matured, renewable row's stage badge. */
graceStatusText(row: FdMaturityRow): string {
if (!row.renewalGracePeriodDays) {
return 'Renewable';
}
const grace = row.renewalGracePeriodDays;
if (row.withinGraceWindow) {
const daysLeft = grace - row.daysOverdue;
return `Renewable — ${daysLeft} of ${grace} grace day${grace === 1 ? '' : 's'} left`;
}
const daysPastGrace = row.daysOverdue - grace;
return `Grace period (${grace}d) passed ${daysPastGrace} day${daysPastGrace === 1 ? '' : 's'} ago`;
}
}
@@ -0,0 +1,240 @@
/**
* 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/.
*/
/**
* Where an active Fixed Deposit sits relative to its own maturity date.
*
* MATURED accounts are the ones staff actually need to act on -- the FD has run its course but is
* still ACTIVE in the core, meaning nobody has applied the maturity instruction yet (or the
* scheduled job hasn't reached it). DUE_SOON and UPCOMING exist so a branch can see what's coming
* before it lands, not just after.
*/
export type FdMaturityStage = 'MATURED' | 'DUE_SOON' | 'UPCOMING' | 'ON_TRACK';
/** Day-count thresholds, measured forward from today toward the maturity date. */
export const FD_MATURITY_POLICY = {
/** Maturing within this many days counts as DUE_SOON. */
dueSoonDays: 7,
/** Maturing within this many days (and not already DUE_SOON) counts as UPCOMING. */
upcomingDays: 30
};
export interface FdMaturityStageMeta {
stage: FdMaturityStage;
label: string;
/** Colour key the template maps to a badge/tile class. */
tone: 'info' | 'warn' | 'danger' | 'neutral';
summary: string;
}
export const FD_MATURITY_STAGES: Record<FdMaturityStage, FdMaturityStageMeta> = {
MATURED: {
stage: 'MATURED',
label: 'Matured',
tone: 'danger',
summary: 'Past maturity date and still active -- the maturity instruction has not been applied yet.'
},
DUE_SOON: {
stage: 'DUE_SOON',
label: `Maturing ≤ ${FD_MATURITY_POLICY.dueSoonDays} Days`,
tone: 'warn',
summary: 'Matures within the week.'
},
UPCOMING: {
stage: 'UPCOMING',
label: `Maturing ≤ ${FD_MATURITY_POLICY.upcomingDays} Days`,
tone: 'info',
summary: 'Matures within the month.'
},
ON_TRACK: {
stage: 'ON_TRACK',
label: 'On Track',
tone: 'neutral',
summary: 'Maturity date is further out.'
}
};
/** FD types the manual-renewal action applies to. SPECIAL is architecturally forced to Transfer to
* Savings server-side and can never be Reinvest-configured, so it's never renewable here. */
const RENEWABLE_FD_TYPES = [
'SINGLE',
'DOUBLE'
];
/** DepositAccountOnClosureType labels, for the "what will happen automatically" sub-label shown on a
* matured row that already carries an explicit instruction from account-opening. */
const ON_CLOSURE_TYPE_LABELS: Record<number, string> = {
200: 'Transfer to Savings',
300: 'Reinvest Principal + Interest',
400: 'Reinvest Principal Only'
};
/** One active Fixed Deposit account, positioned on the maturity ladder. */
export interface FdMaturityRow {
id: number;
accountNo: string;
clientId: number | null;
clientName: string;
productName: string;
maturityDate: string;
depositAmount: number | null;
maturityAmount: number | null;
/** Negative once the maturity date has passed. */
daysRemaining: number;
/** 0 while not yet matured; how many days past maturity otherwise. */
daysOverdue: number;
stage: FdMaturityStage;
meta: FdMaturityStageMeta;
/** Null/0 -- no buffer configured; renewing after the maturity date itself pays gap interest. */
renewalGracePeriodDays: number | null;
linkedAccountId: number | null;
/** The account's own stored DepositAccountOnClosureType, raw -- null when none was chosen at
* account-opening. On a renewable row that DOES carry one (grace-period case: the account picked
* Reinvest, and is only waiting because the product also has a grace window), the Renew dialog
* pre-selects this rather than presenting a neutral, undecided choice -- the maker already decided
* what this FD should do, grace only changed when that gets carried out. */
onClosureType: number | null;
/** True only when the account was opened with NO maturity instruction at all (the officer left
* Maturity Instructions blank at account-opening) -- that's the one case nothing else will ever
* act on this FD, so the Dashboard's manual Renew action is meaningful. An account that WAS given
* an explicit instruction (Transfer to Savings or either Reinvest option) auto-processes on its
* own via the nightly job, exactly as it always has -- the Dashboard only reports on those, it
* doesn't also offer to renew them. */
isReinvestEligible: boolean;
/** True once matured AND eligible -- the only case where nothing has auto-processed it, so a
* manual action from the Dashboard is meaningful. */
renewable: boolean;
/** Only meaningful when renewable -- true if still inside the grace window (backdated renewal,
* no gap interest), false if the window has passed (renews from today, gap interest applies). */
withinGraceWindow: boolean;
/** Set on a matured row that is NOT renewable because it already carries an explicit instruction --
* what it was configured to do at account-opening, shown so staff can see at a glance what tonight's
* job will do without opening the account. Null for a renewable row (nothing decided yet) or a row
* that isn't matured. */
configuredInstructionLabel: string | null;
}
/** Days between two ISO dates, positive when `later` is after `earlier`. */
function daysBetween(earlier: string, later: string): number {
const a = new Date(`${earlier}T00:00:00Z`).getTime();
const b = new Date(`${later}T00:00:00Z`).getTime();
return Math.round((b - a) / 86400000);
}
/** Which rung of the ladder an account is on, from how many days remain until maturity. */
export function maturityStage(daysRemaining: number): FdMaturityStage {
if (daysRemaining <= 0) {
return 'MATURED';
}
if (daysRemaining <= FD_MATURITY_POLICY.dueSoonDays) {
return 'DUE_SOON';
}
if (daysRemaining <= FD_MATURITY_POLICY.upcomingDays) {
return 'UPCOMING';
}
return 'ON_TRACK';
}
/** Builds one dashboard row from a raw `/fixeddepositaccounts` list item and today's ISO date. */
export function buildMaturityRow(
fd: {
id: number;
accountNo: string;
clientId?: number;
clientName?: string;
savingsProductName?: string;
productName?: string;
fdType?: string;
maturityDate: string;
depositAmount?: number;
maturityAmount?: number;
onClosureType?: number;
linkedAccountId?: number;
renewalGracePeriodDays?: number;
},
today: string
): FdMaturityRow | null {
if (!fd.maturityDate) {
// No maturity date means the account isn't fully assembled yet (e.g. a chart not applied) --
// nothing sound to bucket it by, so it's left off the worklist rather than shown wrong.
return null;
}
const daysRemaining = daysBetween(today, fd.maturityDate);
const stage = maturityStage(daysRemaining);
// No grace configured reads as a 0-day buffer, not "not renewable" -- it only shrinks the backdate
// window, matching the backend's default (renewMaturedFixedDeposit treats a missing value as 0).
const gracePeriodDays = fd.renewalGracePeriodDays ?? 0;
// Mirrors updateMaturityDetails()'s own "will I auto-process this?" check exactly, inverted: the
// nightly job leaves an account alone (never touches it) only when it was opened with NO maturity
// instruction at all -- that's the one case the Dashboard's Renew action exists for. An account
// opened WITH an explicit instruction auto-processes tonight regardless of the product's renewal
// grace period (the grace window never delays it), so it's never renewable here either way.
const isReinvestEligible =
!!fd.fdType && RENEWABLE_FD_TYPES.includes(fd.fdType as string) && fd.onClosureType == null;
const renewable = stage === 'MATURED' && isReinvestEligible;
const withinGraceWindow = renewable && daysOverdueFor(daysRemaining) <= gracePeriodDays;
const configuredInstructionLabel =
stage === 'MATURED' && !renewable && fd.onClosureType != null
? (ON_CLOSURE_TYPE_LABELS[fd.onClosureType] ?? null)
: null;
return {
id: fd.id,
accountNo: fd.accountNo || '',
clientId: fd.clientId ?? null,
clientName: fd.clientName || '—',
productName: fd.productName || fd.savingsProductName || '—',
maturityDate: fd.maturityDate,
depositAmount: fd.depositAmount ?? null,
maturityAmount: fd.maturityAmount ?? null,
daysRemaining,
daysOverdue: daysOverdueFor(daysRemaining),
stage,
meta: FD_MATURITY_STAGES[stage],
renewalGracePeriodDays: gracePeriodDays,
linkedAccountId: fd.linkedAccountId ?? null,
onClosureType: fd.onClosureType ?? null,
isReinvestEligible,
renewable,
withinGraceWindow,
configuredInstructionLabel
};
}
function daysOverdueFor(daysRemaining: number): number {
return daysRemaining < 0 ? -daysRemaining : 0;
}
export interface FdMaturitySummary {
total: number;
matured: number;
dueSoon: number;
upcoming: number;
onTrack: number;
}
export function maturitySummary(rows: FdMaturityRow[]): FdMaturitySummary {
return {
total: rows.length,
matured: rows.filter((r) => r.stage === 'MATURED').length,
dueSoon: rows.filter((r) => r.stage === 'DUE_SOON').length,
upcoming: rows.filter((r) => r.stage === 'UPCOMING').length,
onTrack: rows.filter((r) => r.stage === 'ON_TRACK').length
};
}
/** Sorts the worklist so the most overdue, then the soonest-to-mature, rows come first. */
export function sortMaturityRows(rows: FdMaturityRow[]): FdMaturityRow[] {
const rank: Record<FdMaturityStage, number> = {
MATURED: 0,
DUE_SOON: 1,
UPCOMING: 2,
ON_TRACK: 3
};
return [...rows].sort((a, b) => rank[a.stage] - rank[b.stage] || a.daysRemaining - b.daysRemaining);
}
@@ -0,0 +1,22 @@
/**
* 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 { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { FdDashboardRoutingModule } from './fd-dashboard-routing.module';
import { FdDashboardComponent } from './fd-dashboard.component';
@NgModule({
imports: [
SharedModule,
FdDashboardRoutingModule,
FdDashboardComponent
]
})
export class FdDashboardModule {}
@@ -34,6 +34,35 @@
</div>
</div>
<!-- Renewal -->
<div class="fdpt-section">
<div class="fdpt-section__header">
<span class="fdpt-section__badge"><i class="fas fa-sync-alt"></i></span>
<span>Renewal</span>
</div>
<div class="fdpt-body">
<div class="fdpt-banner">
<span class="fdpt-banner__icon">ℹ️</span>
<span>
Leave blank for an FD on this product to keep renewing instantly, the same night it matures, as before. Set a
number of days and a matured "auto renew" FD instead waits on the FD Dashboard for that many days for staff to
renew it manually -- backdated to the original maturity date within that window, or from the day it is
actually renewed (with gap-period interest at the linked Savings account's rate) once the window has passed.
</span>
</div>
<div class="fdpt-grid fdpt-grid--three">
<mat-form-field appearance="outline" class="fdpt-field">
<mat-label>Renewal Grace Period</mat-label>
<input type="number" matInput formControlName="renewalGracePeriodDays" min="0" />
<mat-hint>Days</mat-hint>
@if (fixedDepositProductTermsForm.get('renewalGracePeriodDays').hasError('min')) {
<mat-error>Renewal Grace Period must be zero or greater</mat-error>
}
</mat-form-field>
</div>
</div>
</div>
<!-- Interest Rate & Posting Terms -->
<div class="fdpt-section">
<div class="fdpt-section__header">
@@ -74,7 +74,6 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges
this.periodFrequencyData = this.fixedDepositProductsTemplate.periodFrequencyTypeOptions || [];
const isEditing = !!this.fixedDepositProductsTemplate.id;
const defaultPeriodTypeId = this.periodFrequencyData[0]?.id ?? null;
const defaultAveragePeriod = this.averagePeriodData[0]?.value || 'Monthly';
const savedEnumTypeId =
this.fixedDepositProductsTemplate.interestCalculationType?.id || this.interestCalculationTypeData?.[0]?.id || 1;
@@ -85,12 +84,17 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges
: (this.interestCalculationTypeOptionData?.[0]?.id ?? null);
this.fixedDepositProductTermsForm.patchValue({
minDepositTerm: this.fixedDepositProductsTemplate.minDepositTerm ?? 1,
minDepositTermTypeId: this.fixedDepositProductsTemplate.minDepositTermType?.id ?? defaultPeriodTypeId,
// Hidden, unedited-by-anyone fields -- left blank (like their max-/in-multiples-of- siblings
// already correctly are) rather than forced to a real default, or every single product edit
// submitted a "real" value against a product that had none, and a checker's "Proposed changes"
// review showed these two as "changed" on every unrelated edit, never anything a maker touched.
minDepositTerm: this.fixedDepositProductsTemplate.minDepositTerm ?? '',
minDepositTermTypeId: this.fixedDepositProductsTemplate.minDepositTermType?.id ?? '',
maxDepositTerm: this.fixedDepositProductsTemplate.maxDepositTerm ?? '',
maxDepositTermTypeId: this.fixedDepositProductsTemplate.maxDepositTermType?.id ?? '',
inMultiplesOfDepositTerm: this.fixedDepositProductsTemplate.inMultiplesOfDepositTerm ?? '',
inMultiplesOfDepositTermTypeId: this.fixedDepositProductsTemplate.inMultiplesOfDepositTermType?.id ?? '',
renewalGracePeriodDays: this.fixedDepositProductsTemplate.renewalGracePeriodDays ?? '',
minDepositAmount: this.fixedDepositProductsTemplate.minDepositAmount,
maxDepositAmount: this.fixedDepositProductsTemplate.maxDepositAmount,
nominalAnnualInterestRate: this.fixedDepositProductsTemplate.nominalAnnualInterestRate ?? 0,
@@ -171,6 +175,13 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges
maxDepositTermTypeId: [''],
inMultiplesOfDepositTerm: [''],
inMultiplesOfDepositTermTypeId: [''],
// Days after maturity a depositor can still walk in and have their FD renewed backdated to the
// original maturity date, at the currently active rate -- blank/0 keeps today's instant,
// same-night auto-renewal (see FD Dashboard for the manual renewal this enables).
renewalGracePeriodDays: [
'',
Validators.min(0)
],
minDepositAmount: [
'',
Validators.min(0)
@@ -69,7 +69,14 @@ export class ProductEditDiffDialogComponent implements OnInit {
currencyCode: 'currency.code',
digitsAfterDecimal: 'currency.decimalPlaces',
inMultiplesOf: 'currency.inMultiplesOf',
taxGroupId: 'taxGroup.id'
taxGroupId: 'taxGroup.id',
// Enum fields the stored product exposes as "<name>Type": {id, value}", not "<name>TypeId" --
// without these the "current" side always read as blank, so these showed as changed on every
// edit even when the maker never touched them.
preClosurePenalInterestOnTypeId: 'preClosurePenalInterestOnType.id',
minDepositTermTypeId: 'minDepositTermType.id',
maxDepositTermTypeId: 'maxDepositTermType.id',
inMultiplesOfDepositTermTypeId: 'inMultiplesOfDepositTermType.id'
};
/**
@@ -78,6 +85,17 @@ export class ProductEditDiffDialogComponent implements OnInit {
* `accountingMappingOptions` rather than a per-field `<field>Options` key, so both sides of the
* diff need this table to turn an account id into the account's name.
*/
/**
* Fields whose option list isn't named after the field at all -- min/max/in-multiples-of deposit
* term type all resolve against one shared frequency list (Days/Weeks/Months/Years), not a list of
* their own, so the `<field>Options` convention below never finds them.
*/
private static readonly OPTIONS_KEY_ALIASES: Record<string, string> = {
minDepositTermTypeId: 'periodFrequencyTypeOptions',
maxDepositTermTypeId: 'periodFrequencyTypeOptions',
inMultiplesOfDepositTermTypeId: 'periodFrequencyTypeOptions'
};
private static readonly GL_FIELD_TO_MAPPING: Record<string, string> = {
savingsReferenceAccountId: 'savingsReferenceAccount',
savingsControlAccountId: 'savingsControlAccount',
@@ -149,23 +167,37 @@ export class ProductEditDiffDialogComponent implements OnInit {
/**
* Enum/option fields arrive from the edit as a bare id, which reads as a meaningless number in the
* table. The product fetched with template=true carries the matching option list under a predictable
* `<field>Options` key (accountingRule -> accountingRuleOptions), so the id is resolved back to the
* label the maker actually picked. Returns null when there is no such list to resolve against.
* table. The product fetched with template=true usually carries the matching option list under a
* predictable `<field>Options` key (accountingRule -> accountingRuleOptions), so the id is resolved
* back to the label the maker actually picked. Two other shapes this also has to cover:
* - a field ending in "...TypeId" whose list drops the "Id" (preClosurePenalInterestOnTypeId ->
* preClosurePenalInterestOnTypeOptions), and
* - a field resolving against a shared list that isn't named after the field at all
* (OPTIONS_KEY_ALIASES).
* All three are tried in turn; returns null only when none of them find a match, so the id is
* shown as-is rather than silently wrong.
*/
private resolveOptionLabel(fieldKey: string, id: any, template: any): string | null {
if (ProductEditDiffDialogComponent.GL_FIELD_TO_MAPPING[fieldKey]) {
return this.glAccountNamesById.get(String(id)) ?? null;
}
const options = template?.[`${fieldKey}Options`];
if (!Array.isArray(options)) {
return null;
const candidateKeys = [
ProductEditDiffDialogComponent.OPTIONS_KEY_ALIASES[fieldKey],
`${fieldKey}Options`,
fieldKey.endsWith('Id') ? `${fieldKey.slice(0, -2)}Options` : null
].filter((key): key is string => !!key);
for (const optionsKey of candidateKeys) {
const options = template?.[optionsKey];
if (!Array.isArray(options)) {
continue;
}
const match = options.find((option: any) => String(option?.id) === String(id));
if (match) {
return match.value ?? match.name ?? match.code ?? null;
}
}
const match = options.find((option: any) => String(option?.id) === String(id));
if (!match) {
return null;
}
return match.value ?? match.name ?? match.code ?? null;
return null;
}
/**
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260921',
'hash': '3c363d03'
'version': '260923',
'hash': '3f59cc6b'
},
'allow_switching_backend_instance': true
};
+13
View File
@@ -55,6 +55,19 @@
box-shadow: none;
}
// ===== Payment / Renewal Dialog Panel =====
.pmt-dialog-panel .mat-mdc-dialog-container .mdc-dialog__surface {
padding: 0 !important;
border-radius: 16px !important;
overflow: hidden !important;
box-shadow: 0 20px 40px rgb(15 23 42 / 25%) !important;
}
.pmt-dialog-panel .mat-mdc-dialog-content {
padding: 0 !important;
max-height: none !important;
}
.ipd-dialog .mdc-dialog__surface {
border-radius: 20px !important;
background: transparent !important;