Merge branch 'dev-latest' into feature/bugfix
This commit is contained in:
-296
@@ -1,296 +0,0 @@
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: auto-deploy
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
- dev
|
||||
event:
|
||||
- push
|
||||
|
||||
steps:
|
||||
# ============================================================
|
||||
# DOCKER BUILD & PUSH
|
||||
#
|
||||
# Angular build (npm ci + ng build) Dockerfile ke andar
|
||||
# multi-stage me hota hai, isliye alag build step nahi chahiye.
|
||||
# ============================================================
|
||||
- name: docker-build-push
|
||||
image: docker
|
||||
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run/docker.sock
|
||||
|
||||
commands:
|
||||
- |
|
||||
set -e
|
||||
|
||||
# --------------------------------------------------------
|
||||
# Repository name comes from Drone
|
||||
# --------------------------------------------------------
|
||||
REPO="$${DRONE_REPO_NAME}"
|
||||
REPO="$$(echo "$$REPO" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
IMAGE="harbor.lincpay:8082/lincpay/$$REPO:$${DRONE_BUILD_NUMBER}"
|
||||
|
||||
echo "=========================================="
|
||||
echo "DOCKER BUILD & PUSH"
|
||||
echo "=========================================="
|
||||
echo "Repository : $$REPO"
|
||||
echo "Build : $${DRONE_BUILD_NUMBER}"
|
||||
echo "Image : $$IMAGE"
|
||||
echo "=========================================="
|
||||
|
||||
# --------------------------------------------------------
|
||||
# Harbor login
|
||||
# --------------------------------------------------------
|
||||
echo 'QVgW5QbqlKKtBU2dYApTY28Dd6J36GK3' | \
|
||||
docker login harbor.lincpay:8082 \
|
||||
-u 'robot$lincpay+drone' \
|
||||
--password-stdin
|
||||
|
||||
# --------------------------------------------------------
|
||||
# Build
|
||||
# --------------------------------------------------------
|
||||
echo "Building Docker image..."
|
||||
|
||||
docker build \
|
||||
-t "$$IMAGE" \
|
||||
.
|
||||
|
||||
# --------------------------------------------------------
|
||||
# Push
|
||||
# --------------------------------------------------------
|
||||
echo "Pushing Docker image..."
|
||||
|
||||
docker push "$$IMAGE"
|
||||
|
||||
echo "Docker image pushed successfully."
|
||||
|
||||
# ============================================================
|
||||
# DEPLOY
|
||||
# ============================================================
|
||||
- name: deploy
|
||||
image: docker
|
||||
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run/docker.sock
|
||||
|
||||
- name: envfiles
|
||||
path: /platform-env
|
||||
|
||||
commands:
|
||||
- |
|
||||
set -e
|
||||
|
||||
# ========================================================
|
||||
# REPOSITORY / SERVICE / IMAGE
|
||||
# ========================================================
|
||||
|
||||
REPO="$${DRONE_REPO_NAME}"
|
||||
REPO="$$(echo "$$REPO" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
SERVICE_NAME="$${REPO}_app"
|
||||
|
||||
IMAGE="harbor.lincpay:8082/lincpay/$$REPO:$${DRONE_BUILD_NUMBER}"
|
||||
|
||||
echo "=========================================="
|
||||
echo "DEPLOYMENT"
|
||||
echo "=========================================="
|
||||
echo "Repository : $$REPO"
|
||||
echo "Service : $$SERVICE_NAME"
|
||||
echo "Image : $$IMAGE"
|
||||
echo "Placement : node.labels.environment == dev2"
|
||||
echo "=========================================="
|
||||
|
||||
|
||||
# ========================================================
|
||||
# VALIDATION
|
||||
# ========================================================
|
||||
|
||||
if [ -z "$$REPO" ]; then
|
||||
echo "ERROR: DRONE_REPO_NAME is empty."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$$SERVICE_NAME" ]; then
|
||||
echo "ERROR: Service name is empty."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$$IMAGE" ]; then
|
||||
echo "ERROR: Docker image is empty."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Repository verified : $$REPO"
|
||||
echo "Service verified : $$SERVICE_NAME"
|
||||
echo "Image verified : $$IMAGE"
|
||||
|
||||
|
||||
# ========================================================
|
||||
# HARBOR LOGIN
|
||||
#
|
||||
# Zaroori hai taaki --with-registry-auth worker node (dev2) ko
|
||||
# valid Harbor credentials propagate kare — warna cross-node pull
|
||||
# "No such image" / "no basic auth credentials" se fail hota hai.
|
||||
# ========================================================
|
||||
|
||||
echo 'QVgW5QbqlKKtBU2dYApTY28Dd6J36GK3' | \
|
||||
docker login harbor.lincpay:8082 \
|
||||
-u 'robot$lincpay+drone' \
|
||||
--password-stdin
|
||||
|
||||
|
||||
# ========================================================
|
||||
# EXISTING SERVICE
|
||||
# ========================================================
|
||||
|
||||
if docker service ls --format '{{.Name}}' | grep -Fxq "$$SERVICE_NAME"; then
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "EXISTING SERVICE"
|
||||
echo "=========================================="
|
||||
echo "Service: $$SERVICE_NAME"
|
||||
|
||||
|
||||
# ------------------------------------------------------
|
||||
# Check whether dev constraint already exists
|
||||
# ------------------------------------------------------
|
||||
|
||||
CONSTRAINT_EXISTS=$$(docker service inspect "$$SERVICE_NAME" \
|
||||
--format '{{range .Spec.TaskTemplate.Placement.Constraints}}{{println .}}{{end}}' \
|
||||
| grep -Fx 'node.labels.environment == dev2' || true)
|
||||
|
||||
|
||||
# ------------------------------------------------------
|
||||
# Add constraint if missing
|
||||
# ------------------------------------------------------
|
||||
|
||||
if [ -z "$$CONSTRAINT_EXISTS" ]; then
|
||||
|
||||
echo "Constraint missing."
|
||||
echo "Adding: node.labels.environment == dev2"
|
||||
|
||||
docker service update \
|
||||
--constraint-add 'node.labels.environment == dev2' \
|
||||
"$$SERVICE_NAME"
|
||||
|
||||
else
|
||||
|
||||
echo "Constraint already exists."
|
||||
|
||||
fi
|
||||
|
||||
|
||||
# ------------------------------------------------------
|
||||
# Update image
|
||||
# ------------------------------------------------------
|
||||
|
||||
echo "Updating service image..."
|
||||
|
||||
docker service update \
|
||||
--image "$$IMAGE" \
|
||||
--replicas 1 \
|
||||
--with-registry-auth \
|
||||
"$$SERVICE_NAME"
|
||||
|
||||
|
||||
# ========================================================
|
||||
# NEW SERVICE
|
||||
# ========================================================
|
||||
|
||||
else
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "NEW SERVICE"
|
||||
echo "=========================================="
|
||||
echo "Creating: $$SERVICE_NAME"
|
||||
|
||||
|
||||
# ------------------------------------------------------
|
||||
# Traefik rule
|
||||
#
|
||||
# Construct it without allowing Drone to consume
|
||||
# the shell backticks.
|
||||
# ------------------------------------------------------
|
||||
|
||||
TRAEFIK_RULE='Host(`'$$REPO'.lincpay`)'
|
||||
|
||||
echo "Traefik rule: $$TRAEFIK_RULE"
|
||||
|
||||
|
||||
# ------------------------------------------------------
|
||||
# Create service
|
||||
# ------------------------------------------------------
|
||||
|
||||
docker service create \
|
||||
--name "$$SERVICE_NAME" \
|
||||
--network edge-network \
|
||||
--replicas 1 \
|
||||
--constraint 'node.labels.environment == dev2' \
|
||||
--with-registry-auth \
|
||||
--env-file /platform-env/global.env \
|
||||
--env-file /platform-env/dev.env \
|
||||
--label traefik.enable=true \
|
||||
--label "traefik.docker.network=edge-network" \
|
||||
--label "traefik.http.routers.$$REPO.rule=$$TRAEFIK_RULE" \
|
||||
--label "traefik.http.services.$$REPO.loadbalancer.server.port=80" \
|
||||
"$$IMAGE"
|
||||
|
||||
fi
|
||||
|
||||
|
||||
# ========================================================
|
||||
# FINAL STATUS
|
||||
# ========================================================
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "FINAL SERVICE STATUS"
|
||||
echo "=========================================="
|
||||
|
||||
docker service ls \
|
||||
--filter "name=$$SERVICE_NAME"
|
||||
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "PLACEMENT CONSTRAINT"
|
||||
echo "=========================================="
|
||||
|
||||
docker service inspect "$$SERVICE_NAME" \
|
||||
--format '{{json .Spec.TaskTemplate.Placement}}'
|
||||
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "SERVICE TASKS"
|
||||
echo "=========================================="
|
||||
|
||||
docker service ps "$$SERVICE_NAME" --no-trunc
|
||||
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "DEPLOYMENT COMPLETE"
|
||||
echo "=========================================="
|
||||
|
||||
volumes:
|
||||
# ============================================================
|
||||
# DOCKER SOCKET
|
||||
# ============================================================
|
||||
- name: dockersock
|
||||
host:
|
||||
path: /var/run/docker.sock
|
||||
|
||||
# ============================================================
|
||||
# ENVIRONMENT FILES
|
||||
# ============================================================
|
||||
- name: envfiles
|
||||
host:
|
||||
path: /opt/platform/env
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 & 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
|
||||
|
||||
@@ -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();
|
||||
|
||||
+19
-5
@@ -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>
|
||||
|
||||
+43
-5
@@ -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('');
|
||||
}
|
||||
|
||||
+66
-31
@@ -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>
|
||||
|
||||
+316
-203
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+77
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+5
-4
@@ -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 {}
|
||||
+29
@@ -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">
|
||||
|
||||
+14
-3
@@ -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)
|
||||
|
||||
+44
-12
@@ -70,7 +70,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'
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -79,6 +86,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',
|
||||
@@ -150,23 +168,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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260914',
|
||||
'hash': 'ee5a430b'
|
||||
'version': '260923',
|
||||
'hash': '3f59cc6b'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user