diff --git a/src/app/accounting/search-journal-entry/search-journal-entry.component.html b/src/app/accounting/search-journal-entry/search-journal-entry.component.html index 6938ce4bd..f04105114 100644 --- a/src/app/accounting/search-journal-entry/search-journal-entry.component.html +++ b/src/app/accounting/search-journal-entry/search-journal-entry.component.html @@ -539,7 +539,7 @@
- + # diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts index a8ebfa8de..666f126d5 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts @@ -334,17 +334,18 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit); if (this.principalAmount && this.principalAmount > 0) { - // Convert tenure to months for backend API. A Days tenure under 30 days rounds down to 0 - // months, which the backend rejects -- send it as tenureInDays instead in that case. + // Years and Months convert exactly; a Days tenure is sent as days and never converted. let tenureInMonths: number | undefined; let tenureInDays: number | undefined; if (this.tenurePeriodUnit === 'Years') { tenureInMonths = this.tenureValue * 12; } else if (this.tenurePeriodUnit === 'Months') { tenureInMonths = this.tenureValue; - } else if (enteredDays >= 30) { - tenureInMonths = Math.floor(enteredDays / 30); } else { + // Sent as days, whatever the count. Converting to whole months silently dropped the + // remainder — a 200-day deposit quoted as six months lost 20 days of interest, and the + // screen still said "200 Days" above the figure. The backend takes days directly and + // works the calendar out itself. tenureInDays = enteredDays; } @@ -423,10 +424,17 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan } else { const n = 12 / compMonths; const effectiveMonths = tenureInMonths ?? enteredDays / 30; - const totalCompoundings = Math.floor(effectiveMonths / compMonths); const rPerCompounding = rate / (n * 100); - result.maturityAmount = - Math.round(this.principalAmount * Math.pow(1 + rPerCompounding, totalCompoundings) * 100) / 100; + // Whole compounding periods, then simple interest on the days left over. Flooring the + // periods and stopping there dropped the remainder entirely: a 200-day deposit + // compounding monthly quoted only six months of interest and lost the other twenty + // days, which is the depositor's money. + const wholePeriods = Math.floor(effectiveMonths / compMonths); + const compounded = this.principalAmount * Math.pow(1 + rPerCompounding, wholePeriods); + const leftoverDays = enteredDays - wholePeriods * compMonths * 30; + const leftoverInterest = + leftoverDays > 0 ? (compounded * rate * leftoverDays) / (100 * daysInYearVal) : 0; + result.maturityAmount = Math.round((compounded + leftoverInterest) * 100) / 100; } result.interestEarned = Math.round((result.maturityAmount - this.principalAmount) * 100) / 100; this.matchResult = { ...result }; diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html index 646c9f4f9..94cd1ea54 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html @@ -18,7 +18,7 @@ {{ isDashboardRenewalMode ? 'Renew ' + (dashboardRenewalRow?.accountNo ?? 'this matured FD') + '.' - : "Process this Fixed Deposit now -- works whether it's Active or Matured." + : 'Process this matured Fixed Deposit now.' }}

@@ -40,10 +40,10 @@ } @else {
- @if (isDashboardRenewalMode) { + @if (isMaturedRenewal) {

- {{ dashboardRenewalTimingText }} + {{ maturedRenewalTimingText }}

@if (renewalPreviewLoading) { @@ -97,7 +97,7 @@
{{ - isDashboardRenewalMode || !remainderIsApproximate + isMaturedRenewal || !remainderIsApproximate ? ('labels.inputs.Maturity Amount' | translate) : 'Amount available on the selected date' }} @@ -106,7 +106,7 @@
} - @if (!isDashboardRenewalMode) { + @if (!isMaturedRenewal) { {{ 'labels.inputs.Closed On Date' | translate }} } - @if (!isDashboardRenewalMode) { + @if (!isMaturedRenewal) { {{ 'labels.inputs.Note' | translate }} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts index 789d1e902..d481a87bf 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts @@ -133,29 +133,32 @@ 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.renewalPreviewLoading = true; - this.fixedDepositsService.getFdRenewalPreview(this.data.accountId).subscribe({ - next: (preview: any) => { - this.renewalPreview = preview; - this.renewalPreviewLoading = false; - }, - // Non-fatal -- the plain timing text above still explains what will happen even if the - // numeric breakdown couldn't be fetched; only the numbers are missing, not the action itself. - error: () => { - this.renewalPreviewLoading = false; + // Fetched every time this dialog opens, not just from the FD Dashboard -- Payment is now only ever + // offered once an FD has Matured (see FixedDepositsButtonsConfiguration), so a plain Payment on it + // is exactly the same "late renewal" scenario the Dashboard's Renew action handles, and needs the + // exact same date-locking/gap-interest treatment, or an FD closed this way instead of from the + // Dashboard would silently skip crediting interest it genuinely earned. + this.renewalPreviewLoading = true; + this.fixedDepositsService.getFdRenewalPreview(this.data.accountId).subscribe({ + next: (preview: any) => { + this.renewalPreview = preview; + this.renewalPreviewLoading = false; + if (preview?.isMatured) { + // 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 once matured (see the template), so it can't be moved off this value. + this.paymentForm + .get('closedOnDate') + .setValue(preview.withinGrace ? new Date(preview.maturityDate) : this.maxDate); + this.gapInterestPending = !preview.withinGrace; } - }); - } + }, + // Non-fatal -- Confirm still works from here (backend re-derives the same numbers on submit), + // only the numeric preview and its date-locking are unavailable, not the action itself. + error: () => { + this.renewalPreviewLoading = false; + } + }); 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 @@ -195,13 +198,19 @@ export class PaymentDialogComponent implements OnInit { 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.` + /** True once the loaded preview confirms this FD has actually Matured -- which, since Payment is + * only ever offered on a Matured account, is every real open of this dialog now, from either entry + * point. Drives the date-locking and gap-interest preview/crediting identically either way. */ + get isMaturedRenewal(): boolean { + return !!this.renewalPreview?.isMatured; + } + + /** What clicking Confirm is actually about to do -- shown above the (hidden-picker) date once + * matured, since the date itself is no longer visible to explain it. */ + get maturedRenewalTimingText(): string { + if (!this.renewalPreview) return ''; + return this.renewalPreview.withinGrace + ? `Backdated to its original maturity date (${this.renewalPreview.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.`; } diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.html new file mode 100644 index 000000000..018b95331 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.html @@ -0,0 +1,194 @@ + + +
+
+
+ +
+

{{ 'labels.heading.Premature Close' | translate }}

+

+ Close {{ data.accountNo ?? 'this account' }} before maturity -- interest is recalculated as of the date below, + usually at a lower, penalty rate. +

+
+
+ +
+ +
+ @if (loadFailed) { + + } @else { + + + {{ 'labels.inputs.Premature Close Date' | translate }} + + + + @if (prematureCloseForm.controls.closedOnDate.hasError('required')) { + + {{ 'labels.inputs.Premature Close Date' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + + @if (loadingAmount) { +
+ + Calculating premature-closure amount... +
+ } @else { + @if (maturityAmount !== null && maturityAmount !== undefined) { +
+ @if (data.depositAmount) { +
+ Deposit Amount + {{ data.depositAmount | formatNumber }} +
+ } + @if (data.nominalAnnualInterestRate) { +
+ Contracted Interest Rate + {{ data.nominalAnnualInterestRate }}% +
+ } + @if (data.preClosurePenalApplicable) { +
+ Pre-Closure Penalty (on {{ data.preClosurePenalInterestOnType?.value }}) + − {{ data.preClosurePenalInterest }}% +
+ } + @if (interestEarned !== null) { +
+ Interest Earned (to {{ 'labels.inputs.Premature Close Date' | translate }}) + + {{ interestEarned | formatNumber }} +
+ } +
+ {{ 'labels.inputs.Maturity Amount' | translate }} + {{ maturityAmount | formatNumber }} +
+
+ } + + +
+ @for (closureOption of onAccountClosureOptions; track closureOption) { + + } +
+ @if ( + prematureCloseForm.controls.onAccountClosureId?.hasError('required') && + prematureCloseForm.controls.onAccountClosureId?.touched + ) { +
+ {{ 'labels.inputs.Account action' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} +
+ } + + @if (noDestinationAccounts) { + + } + + @if (prematureCloseForm.contains('toSavingsAccountId')) { + + {{ 'labels.inputs.Transfer to Savings' | translate }} + + @for (account of savingsAccountsData; track account) { + + {{ account.accountNo }} + + } + + @if (prematureCloseForm.controls.toSavingsAccountId?.hasError('required')) { + + {{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + } + + @if (prematureCloseForm.contains('transferDescription')) { + + {{ 'labels.inputs.Transfer Description' | translate }} + + + } + + @if (prematureCloseForm.contains('note')) { + + {{ 'labels.inputs.Note' | translate }} + + + } + } + + @if (submitError) { + + } + + } +
+ +
+ + +
+
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.scss new file mode 100644 index 000000000..289c1380e --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.scss @@ -0,0 +1,388 @@ +/** + * 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/. + */ + +/** + * Premature Close Dialog Styles + * CBSNexora Core Banking System + * + * Same shape/spacing/typography as the Payment dialog (.pmt__*), in this Danger Zone action's own red + * accent on the header and Confirm button, rather than Payment's blue -- an irreversible, usually + * penalty-bearing action reads differently from a routine one, everywhere else stays identical so the + * two dialogs feel like the same product. + */ + +.pcd { + display: flex; + flex-direction: column; + 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; + box-shadow: 0 24px 48px -12px rgb(15 23 42 / 25%); +} + +.pcd__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 26px 30px; + background: linear-gradient(135deg, #7f1d1d 0%, #b91c1c 55%, #dc2626 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; + } + } +} + +.pcd__title { + margin: 0; + font-size: 20px; + font-weight: 800; + color: #fff; + line-height: 1.25; + letter-spacing: -0.01em; +} + +.pcd__subtitle { + margin: 5px 0 0; + font-size: 13px; + color: rgb(255 255 255 / 88%); + line-height: 1.4; +} + +.pcd__content { + padding: 26px 30px !important; + overflow-y: auto; + flex: 1 1 auto; + background: #fbfcfe; + + mat-form-field { + width: 100%; + } +} + +.pcd__loading { + display: flex; + align-items: center; + gap: 12px; + color: #64748b; + font-size: 13px; + padding: 14px 0 18px; +} + +.pcd__preview { + display: flex; + flex-direction: column; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 14px; + padding: 4px 18px; + margin: 0 0 18px; + + &-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 11px 0; + border-bottom: 1px solid #f1f5f9; + + &:last-child { + border-bottom: none; + } + + &--highlight { + .pcd__preview-value { + color: #b91c1c; + font-weight: 800; + } + } + + &--total { + margin: 0 -18px; + padding: 14px 18px; + background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%); + border-bottom: none; + border-radius: 0 0 12px 12px; + + .pcd__preview-label { + color: #b91c1c; + font-weight: 700; + } + + .pcd__preview-value { + color: #0f172a; + font-size: 18px; + font-weight: 800; + } + } + } + + &-label { + font-size: 12.5px; + color: #64748b; + font-weight: 600; + } + + &-value { + font-family: 'JetBrains Mono', 'DM Sans', monospace; + font-size: 14px; + font-weight: 700; + color: #0f172a; + } +} + +.pcd__section-label { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #475569; + margin: 10px 0 12px; +} + +/* Action Options Grid - 2 Equal Columns */ +.pcd__options { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 14px; + margin-bottom: 18px; + + @media (width <= 560px) { + grid-template-columns: 1fr; + } +} + +.pcd__option { + display: flex; + align-items: flex-start; + gap: 14px; + text-align: left; + background: #fff; + border: 1.5px solid #e2e8f0; + border-radius: 14px; + padding: 16px; + cursor: pointer; + font: inherit; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + + &:hover { + border-color: #fca5a5; + background: #f8fafc; + box-shadow: 0 6px 16px rgb(15 23 42 / 6%); + transform: translateY(-1px); + } + + &--active { + border-color: #dc2626; + background: #fef2f2; + box-shadow: 0 0 0 1.5px #dc2626; + + .pcd__option-ico { + background: #dc2626; + color: #fff; + border-color: #dc2626; + } + } + + &-ico { + width: 38px; + height: 38px; + min-width: 38px; + border-radius: 10px; + background: #f1f5f9; + border: 1px solid #e2e8f0; + color: #dc2626; + display: flex; + align-items: center; + justify-content: center; + font-size: 15px; + flex-shrink: 0; + transition: all 0.2s ease; + } + + &-text { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + } + + &-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: #dc2626; + font-size: 17px; + position: absolute; + top: 14px; + right: 14px; + } +} + +.pcd__option-error { + color: #b91c1c; + font-size: 12px; + font-weight: 600; + margin: -10px 0 14px; +} + +.pcd__hint { + display: flex; + 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; + } +} + +.pcd__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; +} + +.pcd__actions { + 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; + + .pcd__btn-cancel { + border-radius: 10px; + font-weight: 600; + color: #475569; + } + + .pcd__btn-submit { + border-radius: 10px; + font-weight: 700; + padding: 0 26px; + height: 42px; + background: linear-gradient(135deg, #b91c1c 0%, #dc2626 100%); + color: #fff; + box-shadow: 0 8px 20px rgb(220 38 38 / 30%); + transition: all 0.2s ease; + + &:hover:not(:disabled) { + box-shadow: 0 10px 24px rgb(220 38 38 / 40%); + transform: translateY(-1px); + } + + &:disabled { + background: #cbd5e1; + box-shadow: none; + color: #64748b; + } + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.ts new file mode 100644 index 000000000..2122653e5 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.ts @@ -0,0 +1,218 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { + MAT_DIALOG_DATA, + MatDialogRef, + MatDialogContent, + MatDialogActions, + MatDialogClose +} from '@angular/material/dialog'; +import { CdkScrollable } from '@angular/cdk/scrolling'; +import { CdkTextareaAutosize } from '@angular/cdk/text-field'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, Validators } from '@angular/forms'; + +/** Custom Services */ +import { FixedDepositsService } from '../../../fixed-deposits.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; +import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * "Premature Close" dialog -- Active-only, no reinvest option (the deposit hasn't matured, so there's + * nothing to renew): pay out in cash, or transfer the pre-maturity balance to a savings account. Same + * `.pcd__*` visual language as the Payment dialog ({@link PaymentDialogComponent}), in this Danger Zone + * action's own red accent rather than Payment's blue, since it's an irreversible, usually + * penalty-bearing action rather than a routine one. + *

+ * The premature amount is date-dependent (unlike Payment's one-shot template load): every change to + * Closed On Date re-calls `calculatePrematureAmount`, same as the full-page form this replaces did. + */ +@Component({ + selector: 'mifosx-premature-close-dialog', + templateUrl: './premature-close-dialog.component.html', + styleUrls: ['./premature-close-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CdkScrollable, + MatDialogContent, + MatDialogActions, + MatDialogClose, + CdkTextareaAutosize, + FaIconComponent, + FormatNumberPipe + ] +}) +export class PrematureCloseDialogComponent implements OnInit { + /** The `onAccountClosureId` codes Premature Close can offer -- same codes Payment/Close use. */ + private static readonly WITHDRAW_DEPOSIT_ID = 100; + private static readonly TRANSFER_TO_SAVINGS_ID = 200; + + private static readonly ACTION_ICONS: Record = { + [PrematureCloseDialogComponent.WITHDRAW_DEPOSIT_ID]: 'money-bill-wave', + [PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'wallet' + }; + + private static readonly ACTION_DESCRIPTIONS: Record = { + [PrematureCloseDialogComponent.WITHDRAW_DEPOSIT_ID]: 'Pay out the full balance in cash', + [PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'Move the full balance to another savings account' + }; + + dialogRef = inject>(MatDialogRef); + data: { + accountId: number | string; + accountNo?: string; + depositAmount?: number; + nominalAnnualInterestRate?: number; + preClosurePenalApplicable?: boolean; + preClosurePenalInterest?: number; + preClosurePenalInterestOnType?: { value: string }; + } = inject(MAT_DIALOG_DATA); + private formBuilder = inject(UntypedFormBuilder); + private fixedDepositsService = inject(FixedDepositsService); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); + + prematureCloseForm: UntypedFormGroup; + onAccountClosureOptions: any[] = []; + savingsAccountsData: any[] = []; + maturityAmount: number = null; + loadingAmount = true; + loadFailed = false; + submitting = false; + submitError: string = null; + minDate = new Date(2000, 0, 1); + maxDate: Date; + + ngOnInit(): void { + this.maxDate = this.settingsService.businessDate; + this.prematureCloseForm = this.formBuilder.group({ + closedOnDate: [ + this.maxDate, + Validators.required + ] + }); + + this.prematureCloseForm + .get('closedOnDate') + .valueChanges.subscribe((date: Date) => this.calculatePrematureAmount(date)); + // Same date the form already starts with -- without this the amount/options never load unless the + // maker happens to touch the date picker, which the full-page form this replaces required too. + this.calculatePrematureAmount(this.maxDate); + } + + private calculatePrematureAmount(date: Date): void { + this.loadingAmount = true; + this.loadFailed = false; + const locale = this.settingsService.language.code; + const dateFormat = this.settingsService.dateFormat; + const data = { + closedOnDate: this.dateUtils.formatDate(date, dateFormat), + dateFormat, + locale + }; + this.fixedDepositsService + .executeFixedDepositsAccountCommand(this.data.accountId as string, 'calculatePrematureAmount', data) + .subscribe({ + next: (response: any) => { + this.savingsAccountsData = response.savingsAccounts ?? []; + this.onAccountClosureOptions = response.onAccountClosureOptions ?? []; + this.maturityAmount = response.maturityAmount; + this.loadingAmount = false; + if (!this.prematureCloseForm.contains('onAccountClosureId')) { + this.prematureCloseForm.addControl('onAccountClosureId', new UntypedFormControl('', Validators.required)); + this.prematureCloseForm.addControl('note', new UntypedFormControl('')); + this.prematureCloseForm + .get('onAccountClosureId') + .valueChanges.subscribe((id: number) => this.onClosureTypeChange(id)); + } + }, + // Without this the spinner would run forever on a failed load, leaving Cancel as the only way out. + error: () => { + this.loadingAmount = false; + this.loadFailed = true; + } + }); + } + + /** Adds/removes the destination-account control to match what the chosen type needs. */ + private onClosureTypeChange(id: number): void { + if (id === PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID) { + if (!this.prematureCloseForm.contains('toSavingsAccountId')) { + this.prematureCloseForm.addControl('toSavingsAccountId', new UntypedFormControl('', Validators.required)); + this.prematureCloseForm.addControl('transferDescription', new UntypedFormControl('')); + } + } else { + this.prematureCloseForm.removeControl('toSavingsAccountId'); + this.prematureCloseForm.removeControl('transferDescription'); + } + } + + /** The gap between the deposit and the computed maturity amount -- the actual interest this premature + * closure earns, at whatever rate (contracted, or the lower pre-closure-penalty one below) applied. */ + get interestEarned(): number | null { + if (this.maturityAmount === null || this.maturityAmount === undefined || !this.data.depositAmount) { + return null; + } + return Number(this.maturityAmount) - Number(this.data.depositAmount); + } + + /** True when the chosen action needs a destination account but the customer has none to pick. */ + get noDestinationAccounts(): boolean { + return ( + !this.loadingAmount && + this.prematureCloseForm?.contains('toSavingsAccountId') && + this.savingsAccountsData.length === 0 + ); + } + + iconFor(id: number): string { + return PrematureCloseDialogComponent.ACTION_ICONS[id] ?? 'circle'; + } + + descriptionFor(id: number): string { + return PrematureCloseDialogComponent.ACTION_DESCRIPTIONS[id] ?? ''; + } + + selectClosureType(id: number): void { + this.prematureCloseForm.get('onAccountClosureId').setValue(id); + this.prematureCloseForm.get('onAccountClosureId').markAsTouched(); + } + + submit(): void { + const formValue = { ...this.prematureCloseForm.value }; + const locale = this.settingsService.language.code; + const dateFormat = this.settingsService.dateFormat; + if (formValue.closedOnDate instanceof Date) { + formValue.closedOnDate = this.dateUtils.formatDate(formValue.closedOnDate, dateFormat); + } + const data = { ...formValue, dateFormat, locale }; + this.submitting = true; + this.submitError = null; + this.fixedDepositsService + .executeFixedDepositsAccountCommand(this.data.accountId as string, 'prematureClose', data) + .subscribe({ + next: () => { + this.dialogRef.close({ confirm: true }); + }, + // Keep the dialog open with the entered values and say what went wrong, rather than leaving a + // permanently disabled Confirm behind whatever modal the global interceptor threw up. + error: (error: any) => { + this.submitting = false; + this.submitError = + error?.error?.defaultUserMessage ?? + error?.error?.errors?.[0]?.defaultUserMessage ?? + 'This account could not be prematurely closed. Please review the details and try again.'; + } + }); + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts index 42f39f6c5..a07dde825 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts @@ -17,6 +17,7 @@ import { CalculateInterestDialogComponent } from './custom-dialogs/calculate-int import { PostInterestDialogComponent } from './custom-dialogs/post-interest-dialog/post-interest-dialog.component'; import { ToggleWithholdTaxDialogComponent } from './custom-dialogs/toggle-withhold-tax-dialog/toggle-withhold-tax-dialog.component'; import { PaymentDialogComponent } from './custom-dialogs/payment-dialog/payment-dialog.component'; +import { PrematureCloseDialogComponent } from './custom-dialogs/premature-close-dialog/premature-close-dialog.component'; import { DepositCertificateDialogComponent } from 'app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component'; /** Custom Button Config. */ @@ -238,7 +239,6 @@ export class FixedDepositAccountViewComponent implements OnInit { case 'Undo Activation': case 'Add Charge': case 'Withdrawn by Client': - case 'Premature Close': case 'Deposit': case 'Withdrawal': this.router.navigate([`actions/${name}`], { relativeTo: this.route }); @@ -267,12 +267,17 @@ export class FixedDepositAccountViewComponent implements OnInit { case 'Payment': this.openPaymentDialog(); break; + case 'Premature Close': + this.openPrematureCloseDialog(); + break; } } /** - * Opens the Payment action -- unlike Premature Close/Close, works whether the account is Active or - * Matured, and is the only place a Partial Reinvest can be done. + * Opens the Payment action -- only offered once the FD has actually Matured (see + * FixedDepositsButtonsConfiguration), and the only place a Partial Reinvest can be done. Also the + * exact same dialog the FD Dashboard's Renew action opens (see fd-dashboard.component.ts's + * openRenewDialog), just without dashboardRenewal data, so its date picker stays visible. */ private openPaymentDialog(): void { const paymentDialogRef = this.dialog.open(PaymentDialogComponent, { @@ -293,6 +298,34 @@ export class FixedDepositAccountViewComponent implements OnInit { }); } + /** + * Opens the Premature Close action -- Active-only, no reinvest option, since the deposit hasn't + * matured yet. + */ + private openPrematureCloseDialog(): void { + const prematureCloseDialogRef = this.dialog.open(PrematureCloseDialogComponent, { + width: '720px', + maxWidth: '95vw', + data: { + accountId: this.fixedDepositsAccountData.id, + accountNo: this.fixedDepositsAccountData.fdAccountNumber || this.fixedDepositsAccountData.accountNo, + // calculatePrematureAmount only ever returns the final maturityAmount, not how it was reached -- + // these come from the account's own data (already loaded for this page) so the dialog can show + // the breakdown (deposit, contracted rate, penalty, interest earned) behind that one number. + depositAmount: this.fixedDepositsAccountData.depositAmount, + nominalAnnualInterestRate: this.fixedDepositsAccountData.nominalAnnualInterestRate, + preClosurePenalApplicable: this.fixedDepositsAccountData.preClosurePenalApplicable, + preClosurePenalInterest: this.fixedDepositsAccountData.preClosurePenalInterest, + preClosurePenalInterestOnType: this.fixedDepositsAccountData.preClosurePenalInterestOnType + } + }); + prematureCloseDialogRef.afterClosed().subscribe((response: any) => { + if (response?.confirm) { + this.reload(); + } + }); + } + /** * Deletes Fixed Deposits Account. */ diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts index 76abd062d..9ce40ede6 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts @@ -110,7 +110,18 @@ export class FixedDepositsButtonsConfiguration { setOptions(status: string) { switch (status) { case 'Active': + this.optionArray = [ + { + name: 'Post Interest' + }, + { + name: 'Add Charge' + } + ]; + break; case 'Matured': + // Payment only makes sense once the deposit has actually matured -- an Active FD that needs + // closing early already has Premature Close for that. this.optionArray = [ { name: 'Post Interest' diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-account-actions.component.html b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-account-actions.component.html index f20e46af8..92b1d954b 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-account-actions.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-account-actions.component.html @@ -24,9 +24,6 @@ @if (actions['Add Charge']) { } -@if (actions['Premature Close']) { - -} @if (actions['Close']) { } diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-account-actions.component.ts b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-account-actions.component.ts index e0b47a2f7..b587839e8 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-account-actions.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-account-actions.component.ts @@ -15,7 +15,6 @@ import { ActivateFixedDepositsAccountComponent } from './activate-fixed-deposits import { UndoApprovalFixedDepositsAccountComponent } from './undo-approval-fixed-deposits-account/undo-approval-fixed-deposits-account.component'; import { WithdrawByClientFixedDepositsAccountComponent } from './withdraw-by-client-fixed-deposits-account/withdraw-by-client-fixed-deposits-account.component'; import { AddChargeFixedDepositsAccountComponent } from './add-charge-fixed-deposits-account/add-charge-fixed-deposits-account.component'; -import { PrematureCloseFixedDepositsAccountComponent } from './premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component'; import { CloseFixedDepositsAccountComponent } from './close-fixed-deposits-account/close-fixed-deposits-account.component'; import { FixedDepositsCashTransactionComponent } from './fixed-deposits-cash-transaction/fixed-deposits-cash-transaction.component'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -35,7 +34,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; UndoApprovalFixedDepositsAccountComponent, WithdrawByClientFixedDepositsAccountComponent, AddChargeFixedDepositsAccountComponent, - PrematureCloseFixedDepositsAccountComponent, CloseFixedDepositsAccountComponent, FixedDepositsCashTransactionComponent ] @@ -52,7 +50,6 @@ export class FixedDepositsAccountActionsComponent { 'Undo Approval': boolean; 'Undo Activation': boolean; 'Add Charge': boolean; - 'Premature Close': boolean; 'Withdrawn by Client': boolean; Withdrawal: boolean; Deposit: boolean; @@ -64,7 +61,6 @@ export class FixedDepositsAccountActionsComponent { 'Undo Approval': false, 'Undo Activation': false, 'Add Charge': false, - 'Premature Close': false, 'Withdrawn by Client': false, Withdrawal: false, Deposit: false diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.html b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.html deleted file mode 100644 index 0d204538d..000000000 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.html +++ /dev/null @@ -1,105 +0,0 @@ - - -

- -
- -
- - {{ 'labels.inputs.Premature Close Date' | translate }} - - - - @if (prematureCloseAccountForm.controls.closedOnDate.hasError('required')) { - - {{ 'labels.inputs.Premature Close Date' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - @if (prematureCloseAccountForm.controls.maturityAmount) { - - {{ 'labels.inputs.Maturity Amount' | translate }} - - - } - - @if (prematureCloseAccountForm.contains('onAccountClosureId')) { - - {{ 'labels.inputs.Action' | translate }} - - @for (closureOption of onAccountClosureOptions; track closureOption) { - - {{ closureOption.value }} - - } - - @if (prematureCloseAccountForm.controls.onAccountClosureId.hasError('required')) { - - {{ 'labels.inputs.Account action' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - } - - @if (prematureCloseAccountForm.contains('toSavingsAccountId')) { - - {{ 'labels.inputs.Transfer to Savings' | translate }} - - @for (account of savingsAccountsData; track account) { - - {{ account.accountNo }} - - } - - @if (prematureCloseAccountForm.controls.toSavingsAccountId.hasError('required')) { - - {{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - } - - @if (prematureCloseAccountForm.contains('transferDescription')) { - - {{ 'labels.inputs.Transfer Description' | translate }} - - - } - - @if (prematureCloseAccountForm.contains('note')) { - - {{ 'labels.inputs.Note' | translate }} - - - } -
-
- - - - - -
-
-
diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.scss b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.scss deleted file mode 100644 index 7c3f1555b..000000000 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.scss +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -.container { - max-width: 37rem; -} diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.ts b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.ts deleted file mode 100644 index 5e4d5dc51..000000000 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.ts +++ /dev/null @@ -1,179 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -/** Angular Imports */ -import { Component, OnInit, inject } from '@angular/core'; -import { - UntypedFormGroup, - UntypedFormBuilder, - Validators, - UntypedFormControl, - ReactiveFormsModule -} from '@angular/forms'; -import { ActivatedRoute, Router, RouterLink } from '@angular/router'; - -/** Custom Services */ -import { FixedDepositsService } from '../../fixed-deposits.service'; -import { SettingsService } from 'app/settings/settings.service'; -import { Dates } from 'app/core/utils/dates'; -import { CdkTextareaAutosize } from '@angular/cdk/text-field'; -import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; - -/** - * Premature Close Fixed Deposits Account Component - */ -@Component({ - selector: 'mifosx-premature-close-fixed-deposits-account', - templateUrl: './premature-close-fixed-deposits-account.component.html', - styleUrls: ['./premature-close-fixed-deposits-account.component.scss'], - imports: [ - ...STANDALONE_SHARED_IMPORTS, - CdkTextareaAutosize - ] -}) -export class PrematureCloseFixedDepositsAccountComponent implements OnInit { - private formBuilder = inject(UntypedFormBuilder); - private fixedDepositsService = inject(FixedDepositsService); - private dateUtils = inject(Dates); - private route = inject(ActivatedRoute); - private router = inject(Router); - private settingsService = inject(SettingsService); - - /** Minimum date allowed. */ - minDate = new Date(2000, 0, 1); - /** Maximum date allowed. */ - maxDate = new Date(); - /** Premature Close FD Account form. */ - prematureCloseAccountForm: UntypedFormGroup; - /** Savings Account Data */ - savingsAccountsData: any; - /** On account Closure Options */ - onAccountClosureOptions: any; - /** Fixed Deposits Account Id */ - accountId: any; - /** Form submission event */ - isSubmitted = false; - - /** - * @param {FormBuilder} formBuilder Form Builder - * @param {FixedDepositsService} fixedDepositsService Fixed Deposits Service - * @param {Dates} dateUtils Date Utils - * @param {ActivatedRoute} route Activated Route - * @param {Router} router Router - * @param {SettingsService} settingsService Settings Service - */ - constructor() { - this.accountId = this.route.parent.snapshot.params['fixedDepositAccountId']; - } - - /** - * Creates the premature close fd account form. - */ - ngOnInit() { - this.maxDate = this.settingsService.businessDate; - this.createPrematureCloseAccountForm(); - this.buildDependencies(); - } - - /** - * Creates the premature close fd account form. - */ - createPrematureCloseAccountForm() { - this.prematureCloseAccountForm = this.formBuilder.group({ - closedOnDate: [ - '', - Validators.required - ] - }); - } - - /** - * Subscribes to value changes of parent control. - */ - buildDependencies() { - this.prematureCloseAccountForm.get('closedOnDate').valueChanges.subscribe((value: Date) => { - if (!this.isSubmitted) { - this.calculatePrematureAmount(value); - } - }); - } - - /** - * Calculates prematue amount based on closed on date. - * @param {Date} date Premature Close Date - */ - calculatePrematureAmount(date: Date) { - const locale = this.settingsService.language.code; - const dateFormat = this.settingsService.dateFormat; - const data = { - closedOnDate: this.dateUtils.formatDate(date, dateFormat), - dateFormat, - locale - }; - this.fixedDepositsService - .executeFixedDepositsAccountCommand(this.accountId, 'calculatePrematureAmount', data) - .subscribe((response: any) => { - this.savingsAccountsData = response.savingsAccounts; - this.onAccountClosureOptions = response.onAccountClosureOptions; - this.prematureCloseAccountForm.addControl( - 'maturityAmount', - new UntypedFormControl({ value: '', disabled: true }) - ); - this.prematureCloseAccountForm.addControl( - 'onAccountClosureId', - new UntypedFormControl('', Validators.required) - ); - this.prematureCloseAccountForm.addControl('note', new UntypedFormControl('')); - this.prematureCloseAccountForm.get('maturityAmount').patchValue(response.maturityAmount); - this.addTransferDetails(); - }); - } - - /** - * Subscribes to value changes of `onAccountClosureId` adds and removes transfer details accordingly. - */ - addTransferDetails() { - this.prematureCloseAccountForm.get('onAccountClosureId').valueChanges.subscribe((id: any) => { - if (id === 200) { - this.prematureCloseAccountForm.addControl( - 'toSavingsAccountId', - new UntypedFormControl('', Validators.required) - ); - this.prematureCloseAccountForm.addControl('transferDescription', new UntypedFormControl('')); - } else { - this.prematureCloseAccountForm.removeControl('toSavingsAccountId'); - this.prematureCloseAccountForm.removeControl('transferDescription'); - } - }); - } - - /** - * Submits the form and premature closes the fd account, - * if successful redirects to the fd account. - */ - submit() { - const prematureCloseAccountFormData = this.prematureCloseAccountForm.value; - this.isSubmitted = true; - const locale = this.settingsService.language.code; - const dateFormat = this.settingsService.dateFormat; - const prevClosedDate: Date = this.prematureCloseAccountForm.value.closedOnDate; - if (prematureCloseAccountFormData.closedOnDate instanceof Date) { - prematureCloseAccountFormData.closedOnDate = this.dateUtils.formatDate(prevClosedDate, dateFormat); - } - const data = { - ...prematureCloseAccountFormData, - dateFormat, - locale - }; - this.fixedDepositsService - .executeFixedDepositsAccountCommand(this.accountId, 'prematureClose', data) - .subscribe(() => { - this.router.navigate(['../../'], { relativeTo: this.route }); - }); - } -} diff --git a/src/app/deposits/fixed-deposits/fixed-deposits.module.ts b/src/app/deposits/fixed-deposits/fixed-deposits.module.ts index 6529b1b04..9aa9db4ba 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits.module.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits.module.ts @@ -35,7 +35,6 @@ import { ViewTransactionComponent } from './fixed-deposit-account-view/view-tran import { WaiveChargeDialogComponent } from './fixed-deposit-account-view/custom-dialogs/waive-charge-dialog/waive-charge-dialog.component'; import { InactivateChargeDialogComponent } from './fixed-deposit-account-view/custom-dialogs/inactivate-charge-dialog/inactivate-charge-dialog.component'; import { AddChargeFixedDepositsAccountComponent } from './fixed-deposits-account-actions/add-charge-fixed-deposits-account/add-charge-fixed-deposits-account.component'; -import { PrematureCloseFixedDepositsAccountComponent } from './fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component'; import { CloseFixedDepositsAccountComponent } from './fixed-deposits-account-actions/close-fixed-deposits-account/close-fixed-deposits-account.component'; import { CreateFixedDepositAccountComponent } from './create-fixed-deposit-account/create-fixed-deposit-account.component'; import { FixedDepositAccountDetailsStepComponent } from './fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component'; @@ -78,7 +77,6 @@ import { FixedDepositsCashTransactionComponent } from './fixed-deposits-account- WaiveChargeDialogComponent, InactivateChargeDialogComponent, AddChargeFixedDepositsAccountComponent, - PrematureCloseFixedDepositsAccountComponent, CloseFixedDepositsAccountComponent, CreateFixedDepositAccountComponent, FixedDepositAccountDetailsStepComponent, diff --git a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html index 0e5f05cb9..9c8f007c2 100644 --- a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html +++ b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html @@ -27,101 +27,104 @@ } @else { @if (showRates) { -
-

Reserve rates

-

- Rates are effective-dated rather than overwritten: a return filed for an earlier period keeps reporting the rate - that was actually in force then. Saving against a date that already has a rate edits it; a new date adds one. -

+
+

Reserve rates

+

+ Rates are effective-dated rather than overwritten: a return filed for an earlier period keeps reporting the + rate that was actually in force then. Saving against a date that already has a rate edits it; a new date adds + one. +

-
- @for (type of ['CRR', 'SLR', 'SAVINGS_DEMAND']; track type) { -
- - {{ type === 'SAVINGS_DEMAND' ? 'Savings demand share' : type }} in force today - - @if (currentRate(type); as rate) { - {{ rate.percentage }}% - @if (rate.seedDefault) { - - Placeholder — confirm the rate actually in force - - } @else { - since {{ rate.effectiveFrom }} - } - } @else { - Not set - {{ type === 'CRR' ? 'Form 1' : 'Form 9' }} cannot compute - } -
- } -
- -
- - Rate - - CRR — Form 1 - SLR — Form 9 - - Savings — demand share - - - - - Percentage (%) - - - - - Effective from - - - - -
- - - - - - - - - - - - - @for (rate of rates; track rate.id) { - - - - - - - + } @else { + Not set + {{ type === 'CRR' ? 'Form 1' : 'Form 9' }} cannot compute + } + } - -
RatePercentageEffective fromStatus
{{ rate.rateType }}{{ rate.percentage }}%{{ rate.effectiveFrom }} - @if (isFuture(rate)) { - Future-dated - } @else if (currentRate(rate.rateType)?.id === rate.id) { - In force - } @else { - Superseded - } +
+ @for (type of ['CRR', 'SLR', 'SAVINGS_DEMAND']; track type) { +
+ + {{ type === 'SAVINGS_DEMAND' ? 'Savings demand share' : type }} in force today + + @if (currentRate(type); as rate) { + {{ rate.percentage }}% @if (rate.seedDefault) { - Placeholder + + Placeholder — confirm the rate actually in force + + } @else { + since {{ rate.effectiveFrom }} } -
- -
-
+
+ +
+ + Rate + + CRR — Form 1 + SLR — Form 9 + + Savings — demand share + + + + + Percentage (%) + + + + + Effective from + + + + +
+ + + + + + + + + + + + + @for (rate of rates; track rate.id) { + + + + + + + + } + +
RatePercentageEffective fromStatus
+ {{ rate.rateType }} + {{ rate.percentage }}%{{ rate.effectiveFrom }} + @if (isFuture(rate)) { + Future-dated + } @else if (currentRate(rate.rateType)?.id === rate.id) { + In force + } @else { + Superseded + } + @if (rate.seedDefault) { + Placeholder + } + + +
+ } @@ -324,6 +327,17 @@
+ +

+ Naming a head Memorandum - …, Annexure A - … or + Annexure B - … prints it under that section of the return instead of in the main + statement. +

+ diff --git a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.scss b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.scss index 4b597578c..2660489ab 100644 --- a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.scss +++ b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.scss @@ -297,7 +297,7 @@ $red: #c5221f; &--active { border-color: $primary; - background: rgba(19, 83, 160, 0.06); + background: rgb(19 83 160 / 6%); color: $primary; } @@ -306,13 +306,13 @@ $red: #c5221f; font-weight: 500; padding: 1px 7px; border-radius: 10px; - background: rgba(176, 96, 0, 0.12); + background: rgb(176 96 0 / 12%); color: $amber; // A fully mapped return is worth stating as plainly as an incomplete one, so the tab row can // be read at a glance instead of by elimination. &--done { - background: rgba(19, 115, 51, 0.12); + background: rgb(19 115 51 / 12%); color: $green; } } @@ -379,6 +379,12 @@ $red: #c5221f; } } +// Spans the add-head form's columns rather than sitting in one of them. +.rrc-add-role__note { + flex-basis: 100%; + margin: 0 !important; +} + .rrc-remove { min-width: 0 !important; padding: 0 8px !important; @@ -397,7 +403,7 @@ $red: #c5221f; padding: 3px 10px; } -@media (max-width: 720px) { +@media (width <= 720px) { .rrc-rate-form__type, .rrc-rate-form__pct, .rrc-rate-form__date, @@ -419,7 +425,7 @@ $red: #c5221f; font-size: 0.72rem; letter-spacing: 0.04em; text-transform: uppercase; - color: rgba(0, 0, 0, 0.55); + color: rgb(0 0 0 / 55%); font-weight: 600; padding: 0 0.5rem 0.25rem 0; } @@ -447,7 +453,7 @@ $red: #c5221f; /* An incomplete row is marked where it is, rather than only by the disabled Save button, so it is clear which row is holding the save back. */ &__row--invalid td { - background: rgba(214, 106, 0, 0.06); + background: rgb(214 106 0 / 6%); } } @@ -460,7 +466,7 @@ $red: #c5221f; .rrc-add { margin-top: 1.25rem; padding-top: 1rem; - border-top: 1px dashed rgba(0, 0, 0, 0.12); + border-top: 1px dashed rgb(0 0 0 / 12%); &__form { display: flex; diff --git a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts index 3499691c4..73a9c7de2 100644 --- a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts +++ b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts @@ -91,12 +91,19 @@ export class RbiReserveConfigComponent implements OnInit { { value: 'TIME', label: 'Time' } ]; - /** How a head an administrator adds is consumed by the return. */ + /** + * How a head an administrator adds is consumed by the return. + * + * MAINTAINED and SLR_ASSET are separate because the two returns admit different assets: CRR is + * met in cash and balances with banks, SLR in cash, gold and approved securities. One option for + * both would let a head meant for Form 1 quietly satisfy Form 9. + */ readonly roleUsages = [ - { value: 'LIABILITY', label: 'Liability — adds to the DTL base' }, - { value: 'ASSET_NET', label: 'Asset — netted off the base (NDTL)' }, - { value: 'MAINTAINED', label: 'Counts toward what is maintained' }, - { value: 'MEMO', label: 'Memo — shown but totals nothing' } + { value: 'LIABILITY', label: 'Liability — adds to the DTL base (Form 1 & 9)' }, + { value: 'ASSET_NET', label: 'Asset — netted off the base to give NDTL (Form 1 & 9)' }, + { value: 'MAINTAINED', label: 'Counts toward CRR maintained (Form 1)' }, + { value: 'SLR_ASSET', label: 'Counts toward SLR maintained (Form 9)' }, + { value: 'MEMO', label: 'Memo — shown on the return but totals nothing' } ]; /** New-head form. */ @@ -168,10 +175,12 @@ export class RbiReserveConfigComponent implements OnInit { } byGroup.get(group)!.roles.push(role); } - this.roleGroups = [...byGroup.entries()].map(([ - name, - entry - ]) => ({ name, label: entry.label, roles: entry.roles })); + this.roleGroups = [...byGroup.entries()].map( + ([ + name, + entry + ]) => ({ name, label: entry.label, roles: entry.roles }) + ); // Keep the open tab if it still exists, so saving a role does not throw the user back to the // first tab with their place lost. @@ -237,8 +246,7 @@ export class RbiReserveConfigComponent implements OnInit { // a bank look compliant when it is not. this.selection[role.configKey] = [ ...(this.selection[role.configKey] || []), - { glAccountId: null as any, weightPercentage: 100, liabilityClass: role.classified ? 'TIME' : null } - ]; + { glAccountId: null as any, weightPercentage: 100, liabilityClass: role.classified ? 'TIME' : null }]; } removeMapping(role: any, index: number): void { @@ -252,9 +260,7 @@ export class RbiReserveConfigComponent implements OnInit { */ availableAccounts(role: any, index: number): any[] { const rows = this.selection[role.configKey] || []; - const taken = new Set( - rows.filter((_, i) => i !== index).map((m) => `${m.glAccountId}/${m.liabilityClass ?? ''}`) - ); + const taken = new Set(rows.filter((_, i) => i !== index).map((m) => `${m.glAccountId}/${m.liabilityClass ?? ''}`)); const current = rows[index]; return this.glAccountOptions.filter((o) => !taken.has(`${o.id}/${current?.liabilityClass ?? ''}`)); } diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-accounting-step/fixed-deposit-product-accounting-step.component.ts b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-accounting-step/fixed-deposit-product-accounting-step.component.ts index 921f9773e..0f6bb338f 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-accounting-step/fixed-deposit-product-accounting-step.component.ts +++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-accounting-step/fixed-deposit-product-accounting-step.component.ts @@ -308,6 +308,11 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha setRequired('incomeFromPenaltyAccountId', cashOrAccrual); setRequired('feesReceivableAccountId', accrualOnly); setRequired('penaltiesReceivableAccountId', accrualOnly); + // Required only on accrual, where there is no posting without it. On cash it stays optional but + // is offered, because interest posting routes through it when the product names one (see + // AccountingProcessorHelper.createInterestPostingJournalEntriesForSavings): the payable carries + // interest declared but not yet credited, and a product that names none collapses straight to + // Savings Control instead. setRequired('interestPayableAccountId', accrualOnly); setRequired('escheatLiabilityId', cashOrAccrual && this.isDormancyTrackingEnabled()); @@ -326,8 +331,8 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha rows: [ { key: 'savingsReferenceAccountId', - label: 'Savings Reference Account', - inputLabel: 'Savings reference account', + label: 'Fixed Deposit Reference Account', + inputLabel: 'Fixed deposit reference account', controlName: 'savingsReferenceAccountId', options: this.assetAccountOptions, required: true @@ -360,8 +365,8 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha rows: [ { key: 'savingsControlAccountId', - label: 'Savings Control Account', - inputLabel: 'Savings control account', + label: 'Fixed Deposit Control Account', + inputLabel: 'Fixed deposit control account', controlName: 'savingsControlAccountId', options: this.liabilityAccountOptions, required: true @@ -375,12 +380,16 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha required: true }, { + // Offered on cash accounting too, not only accrual: interest posting routes through + // this account whichever basis the product uses, so that interest declared but not yet + // credited to the member reads as the bank's own liability rather than appearing only + // when it is paid. Optional on cash - a product naming none posts straight to Savings + // Control as before. key: 'interestPayableAccountId', label: 'Interest Payable Account', inputLabel: 'Interest payable account', controlName: 'interestPayableAccountId', - options: this.liabilityAccountOptions, - accrualOnly: true + options: this.liabilityAccountOptions }, { key: 'escheatLiabilityId', @@ -402,8 +411,8 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha rows: [ { key: 'interestOnSavingsAccountId', - label: 'Interest On Savings Account', - inputLabel: 'Interest on savings account', + label: 'Interest On Fixed Deposits', + inputLabel: 'Interest on fixed deposits', controlName: 'interestOnSavingsAccountId', options: this.expenseAccountOptions, required: true diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-preview-step/fixed-deposit-product-preview-step.component.html b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-preview-step/fixed-deposit-product-preview-step.component.html index e92b052fd..8a6431b90 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-preview-step/fixed-deposit-product-preview-step.component.html +++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-preview-step/fixed-deposit-product-preview-step.component.html @@ -481,6 +481,7 @@ } diff --git a/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.html b/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.html index cb3324ea9..458eece5b 100644 --- a/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.html +++ b/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.html @@ -607,6 +607,7 @@ } diff --git a/src/app/rbi-circulars/rbi-circulars.service.ts b/src/app/rbi-circulars/rbi-circulars.service.ts index 6558ba987..38fd0c260 100644 --- a/src/app/rbi-circulars/rbi-circulars.service.ts +++ b/src/app/rbi-circulars/rbi-circulars.service.ts @@ -121,6 +121,47 @@ export class RbiCircularsService { return this.http.get('/ossreturn/oss2', { params: this.ossParams(quarterEndDate, officeId) }); } + // ── Saved returns ── + + /** + * Returns saved earlier, newest first. A saved return is served from the snapshot taken when it + * was saved, never recomputed — a backdated entry or a changed mapping would otherwise produce a + * different statement from the one that was reviewed. + */ + getSavedReturns(formCode?: string): Observable { + let params = new HttpParams(); + if (formCode) { + params = params.set('formCode', formCode); + } + return this.http.get('/rbiformafiling', { params }); + } + + /** One saved return, with the stored snapshot, so the screen renders what was saved. */ + getSavedReturn(filingId: number): Observable { + return this.http.get(`/rbiformafiling/${filingId}`); + } + + /** Keeps the return currently on screen. Re-saving the same form and date replaces it. */ + saveReturn(formCode: string, reportingDate: string, report: any, remarks?: string): Observable { + return this.http.post('/rbiformafiling', { + formCode, + reportingDate, + report, + remarks, + locale: 'en', + dateFormat: 'yyyy-MM-dd' + }); + } + + /** Moves a saved return along its filing lifecycle; the snapshot itself is untouched. */ + updateSavedReturnStatus(filingId: number, status: string, remarks?: string): Observable { + return this.http.post(`/rbiformafiling/${filingId}/status`, { status, remarks }); + } + + deleteSavedReturn(filingId: number): Observable { + return this.http.delete(`/rbiformafiling/${filingId}`); + } + private ossParams(quarterEndDate?: string, officeId?: number): HttpParams { let params = new HttpParams(); if (quarterEndDate) { diff --git a/src/app/rbi-circulars/reserve-return/form1-crr.component.ts b/src/app/rbi-circulars/reserve-return/form1-crr.component.ts index 2db6c5c69..c5935ff16 100644 --- a/src/app/rbi-circulars/reserve-return/form1-crr.component.ts +++ b/src/app/rbi-circulars/reserve-return/form1-crr.component.ts @@ -30,6 +30,7 @@ import { ReserveReturnBase } from './reserve-return.base'; }) export class Form1CrrComponent extends ReserveReturnBase implements OnInit { protected readonly exportStem = 'RBI_Form1_CRR'; + protected readonly formCode = 'FORM_1'; protected fetch(reportingDate: string): Observable { return this.rbiService.getForm1(reportingDate); @@ -37,5 +38,6 @@ export class Form1CrrComponent extends ReserveReturnBase implements OnInit { ngOnInit(): void { this.load(); + this.loadSaved(); } } diff --git a/src/app/rbi-circulars/reserve-return/form9-slr.component.ts b/src/app/rbi-circulars/reserve-return/form9-slr.component.ts index 4dee07ffe..656b1fdc3 100644 --- a/src/app/rbi-circulars/reserve-return/form9-slr.component.ts +++ b/src/app/rbi-circulars/reserve-return/form9-slr.component.ts @@ -30,6 +30,7 @@ import { ReserveReturnBase } from './reserve-return.base'; }) export class Form9SlrComponent extends ReserveReturnBase implements OnInit { protected readonly exportStem = 'RBI_Form9_SLR'; + protected readonly formCode = 'FORM_9'; protected fetch(reportingDate: string): Observable { return this.rbiService.getForm9(reportingDate); @@ -37,5 +38,6 @@ export class Form9SlrComponent extends ReserveReturnBase implements OnInit { ngOnInit(): void { this.load(); + this.loadSaved(); } } diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.base.ts b/src/app/rbi-circulars/reserve-return/reserve-return.base.ts index 65642760c..efa7f5962 100644 --- a/src/app/rbi-circulars/reserve-return/reserve-return.base.ts +++ b/src/app/rbi-circulars/reserve-return/reserve-return.base.ts @@ -64,7 +64,12 @@ export interface SectionRow { * and topped up on the last one fails on the daily test while passing on the closing position. */ export interface FortnightMaintenance { - days: { date: string; amount: number }[]; + /** Each day of the month, with the requirement it was measured against and the gap. */ + days: { date: string; required: number; amount: number; surplus: number }[]; + /** The month the table covers. */ + periodStart?: string; + periodEnd?: string; + periodLabel?: string; dayCount: number; average: number; lowest: number; @@ -146,6 +151,9 @@ export abstract class ReserveReturnBase { /** Filename stem for the Excel export. */ protected abstract readonly exportStem: string; + /** Which return this screen is, so saved returns list only this one's. */ + protected abstract readonly formCode: string; + data: ReserveReturn | null = null; loading = true; errorMsg = ''; @@ -158,6 +166,33 @@ export abstract class ReserveReturnBase { toastVisible = false; private toastTimer: any; + /** Returns saved earlier, listed under the live one. */ + savedReturns: any[] = []; + savedLoading = false; + saving = false; + savedRemarks = ''; + + /** + * Which half of the screen is showing: the return itself, or the ones kept on record. + * + * Two tabs rather than one long page: the live return and the saved list are read at different + * times and for different reasons, and stacking them meant scrolling past the whole statement - + * including a month of daily rows - to reach the list at all. + */ + activeTab: 'report' | 'saved' = 'report'; + + showTab(tab: 'report' | 'saved'): void { + this.activeTab = tab; + } + + /** The saved return currently open below the list, or null when none is. */ + viewingSaved: any = null; + /** + * The opened snapshot's own statement, rendered in its own section. Held separately from `data` so + * the live return stays on screen alongside it and the two can be compared directly. + */ + savedData: ReserveReturn | null = null; + load(): void { this.loading = true; this.errorMsg = ''; @@ -224,6 +259,167 @@ export abstract class ReserveReturnBase { return Math.abs(this.data?.surplusOrShortfall ?? 0); } + // ── Saved returns ── + + loadSaved(): void { + this.savedLoading = true; + this.rbiService + .getSavedReturns(this.formCode) + .pipe(catchError(() => of([]))) + .subscribe((rows: any[]) => { + this.savedLoading = false; + this.savedReturns = rows || []; + }); + } + + /** Keeps the return on screen, so it can be read again later exactly as it stands now. */ + saveCurrent(): void { + if (!this.data) { + return; + } + this.saving = true; + this.rbiService + .saveReturn(this.data.formCode, this.data.reportingDate, this.data, this.savedRemarks || undefined) + .pipe( + catchError((err: any) => { + this.saving = false; + this.toast(err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this return.'); + return of(null); + }) + ) + .subscribe((rows: any[] | null) => { + this.saving = false; + if (!rows) { + return; + } + this.savedReturns = rows; + this.savedRemarks = ''; + this.toast('Return saved.'); + }); + } + + /** + * Opens a saved return in its own section below the list, leaving the live return on screen. + * + * Deliberately not in place of the live one: the reason to open a saved return is almost always to + * compare it against where the bank stands now, and swapping the live figures out makes that + * comparison impossible without clicking back and forth from memory. + */ + viewSaved(row: any): void { + // Clicking the open one closes it, so the button toggles rather than re-fetching. + if (this.viewingSaved?.id === row.id) { + this.closeSaved(); + return; + } + this.rbiService + .getSavedReturn(row.id) + .pipe( + catchError(() => { + this.toast('Could not open this saved return.'); + return of(null); + }) + ) + .subscribe((saved: any) => { + if (!saved?.report) { + this.toast('This saved return has no stored statement.'); + return; + } + this.viewingSaved = saved; + this.savedData = saved.report as ReserveReturn; + // Opens on the Saved tab, where the list it was chosen from is - never jumping the user to + // the live return, which is a different statement. + this.activeTab = 'saved'; + setTimeout(() => document.getElementById('rr-saved-view')?.scrollIntoView({ behavior: 'smooth' }), 0); + }); + } + + /** Closes the opened snapshot. The live return was never hidden, so nothing is restored. */ + closeSaved(): void { + this.viewingSaved = null; + this.savedData = null; + } + + /** Kept for callers that still name the old action. */ + backToLive(): void { + this.closeSaved(); + } + + /** Magnitude of the opened snapshot's gap, so the sign lives in its label rather than the number. */ + get savedSurplus(): number { + return Math.abs(this.savedData?.surplusOrShortfall ?? 0); + } + + updateSavedStatus(row: any, status: string): void { + this.rbiService + .updateSavedReturnStatus(row.id, status) + .pipe( + catchError((err: any) => { + this.toast(err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not update the status.'); + return of(null); + }) + ) + .subscribe((rows: any[] | null) => { + if (!rows) { + return; + } + this.savedReturns = rows; + if (this.viewingSaved?.id === row.id) { + this.viewingSaved = { ...this.viewingSaved, status }; + } + this.toast(`Marked ${this.statusLabel(status)}.`); + }); + } + + deleteSaved(row: any): void { + this.rbiService + .deleteSavedReturn(row.id) + .pipe( + catchError(() => { + this.toast('Could not remove this saved return.'); + return of(null); + }) + ) + .subscribe((rows: any[] | null) => { + if (!rows) { + return; + } + this.savedReturns = rows; + if (this.viewingSaved?.id === row.id) { + this.backToLive(); + } + this.toast('Saved return removed.'); + }); + } + + /** The stages a return moves through, in order, for the status menu. */ + readonly filingStatuses = [ + 'DRAFT', + 'GENERATED', + 'CHECKED', + 'APPROVED', + 'PROVISIONAL_SUBMITTED', + 'FINAL_SUBMITTED', + 'REVISED' + ]; + + statusLabel(status: string): string { + const labels: Record = { + DRAFT: 'Draft', + GENERATED: 'Generated', + CHECKED: 'Checked', + APPROVED: 'Approved', + PROVISIONAL_SUBMITTED: 'Provisional submitted', + FINAL_SUBMITTED: 'Final submitted', + REVISED: 'Revised' + }; + return labels[status] ?? status; + } + + /** Hands the rendered return to the browser's print dialog, which also prints to PDF. */ + printReturn(): void { + window.print(); + } + /** * True when a head takes only part of an account, so the printed figure is not that account's * balance. Anything at or above 100, or unset, is the whole balance and needs no explanation. @@ -268,17 +464,25 @@ export abstract class ReserveReturnBase { try { const wb = newWorkbook(); const sheet = wb.addWorksheet(this.data.formCode.replace('_', ' ')); + // Five columns because the daily table is the widest block on the sheet; the statement above + // it uses the first three and leaves the rest empty. sheet.columns = [ { width: 52 }, { width: 22 }, - { width: 20 }]; + { width: 20 }, + { width: 22 }, + { width: 16 }]; addBanner(sheet, `${this.data.formName} — ${this.data.frequency}`, 'C'); let start = sheet.rowCount + 1; this.addKeyValue(sheet, 'Reporting date', this.data.reportingDate); if (this.data.fortnightLabel) { - this.addKeyValue(sheet, 'Fortnight', `${this.data.fortnightLabel} (${this.data.fortnightStart} to ${this.data.fortnightEnd})`); + this.addKeyValue( + sheet, + 'Fortnight', + `${this.data.fortnightLabel} (${this.data.fortnightStart} to ${this.data.fortnightEnd})` + ); } this.addKeyValue(sheet, 'DTL base as at', this.data.basis.dtlAsOnDate); this.addKeyValue(sheet, 'Balances maintained as at', this.data.basis.holdingsAsOnDate); @@ -388,16 +592,26 @@ export abstract class ReserveReturnBase { this.addKeyValue(sheet, 'Average maintained', this.amount(m.average)); this.addKeyValue(sheet, 'Required average', this.amount(m.requiredAverage)); this.addKeyValue(sheet, 'Average test', m.averageMet ? 'Met' : 'NOT MET'); - this.addKeyValue(sheet, `Daily floor (${m.dailyMinimumPercent}% of requirement)`, this.amount(m.dailyMinimumAmount)); + this.addKeyValue( + sheet, + `Daily floor (${m.dailyMinimumPercent}% of requirement)`, + this.amount(m.dailyMinimumAmount) + ); this.addKeyValue(sheet, 'Lowest day', `${this.amount(m.lowest)} on ${m.lowestOn}`); this.addKeyValue(sheet, 'Daily minimum test', m.dailyMinimumMet ? 'Met' : 'NOT MET'); - this.addKeyValue(sheet, 'Days counted', `${m.dayCount}${m.fortnightComplete ? ' (full fortnight)' : ' (to reporting date)'}`); + this.addKeyValue( + sheet, + 'Days counted', + `${m.dayCount}${m.fortnightComplete ? ' (full fortnight)' : ' (to reporting date)'}` + ); boxAllCells(sheet, start, sheet.rowCount, 2); sheet.addRow([]); const dayHead = sheet.addRow([ 'Date', - 'Maintained (INR)', + 'Required (INR)', + 'Actual (INR)', + 'Surplus / (Shortfall)', 'Against floor' ]); dayHead.font = { bold: true }; @@ -406,13 +620,21 @@ export abstract class ReserveReturnBase { for (const day of m.days) { const row = sheet.addRow([ day.date, + day.required, day.amount, + day.surplus, day.amount < m.dailyMinimumAmount ? 'Below floor' : 'OK' ]); - row.getCell(2).numFmt = '#,##0.00'; - row.getCell(2).alignment = { horizontal: 'right' }; + for (const col of [ + 2, + 3, + 4 + ]) { + row.getCell(col).numFmt = '#,##0.00'; + row.getCell(col).alignment = { horizontal: 'right' }; + } } - boxAllCells(sheet, dayStart, sheet.rowCount, 3); + boxAllCells(sheet, dayStart, sheet.rowCount, 5); } // The Memorandum and Annexures, each under its own heading. diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.component.html b/src/app/rbi-circulars/reserve-return/reserve-return.component.html index 5dcc3af53..2f8dbc1a9 100644 --- a/src/app/rbi-circulars/reserve-return/reserve-return.component.html +++ b/src/app/rbi-circulars/reserve-return/reserve-return.component.html @@ -32,6 +32,14 @@
+ + + + + + @if (activeTab === 'report') { + - @if (data.unmetRequirements.length) { -
- - {{ data.computable ? 'Check this before filing.' : 'This return cannot be computed yet.' }} - - - - Open RBI Reserve Configuration → - -
- } + @if (data.unmetRequirements.length) { +
+ + {{ data.computable ? 'Check this before filing.' : 'This return cannot be computed yet.' }} + + + + Open RBI Reserve Configuration → + +
+ } - -
-
-
- DTL base as at - {{ data.basis.dtlAsOnDate }} -
-
-
- Balances maintained as at - {{ data.basis.holdingsAsOnDate }} -
- @if (data.basis.rawLagDate) { -
- {{ data.basis.lagDays }}-day lag falls on - {{ data.basis.rawLagDate }} + +
+
+
+ DTL base as at + {{ data.basis.dtlAsOnDate }}
- } +
+
+ Balances maintained as at + {{ data.basis.holdingsAsOnDate }} +
+ @if (data.basis.rawLagDate) { +
+ {{ data.basis.lagDays }}-day lag falls on + {{ data.basis.rawLagDate }} +
+ } +
+

{{ data.basis.note }}

-

{{ data.basis.note }}

-
- - @if (data.computable) { -
- + @if (data.computable) { +
+ -
- NDTL base - {{ amount(data.ndtlAmount) }} - - DTL {{ amount(data.dtlAmount) }} less {{ amount(data.interBankAssets) }} · as at - {{ data.basis.dtlAsOnDate }} - -
-
- {{ data.rateType }} rate - {{ data.ratePercentage }}% - @if (data.rateIsSeedDefault) { - Placeholder — not confirmed - } @else { - in force on {{ data.reportingDate }} - } -
-
- Required - {{ amount(data.requiredAmount) }} - {{ data.ratePercentage }}% of NDTL -
-
- Maintained - {{ amount(data.maintainedAmount) }} - {{ percent(data.maintainedPercentage) }} of NDTL -
-
- {{ isShortfall ? 'Shortfall' : 'Surplus' }} - {{ amount(shortfallAmount) }} - {{ isShortfall ? 'Below requirement' : 'Above requirement' }} -
-
- } - - -
- - - - - - - - - - - @for (line of data.lines; track $index) { - - - - - - - @for (account of line.accounts; track account.glAccountId) { - - - - - - } - } - - @if (data.computable) { - - - - - - - - - - - - - - - - } - -
ParticularsAs atAmount (INR)
- {{ line.label }} - @if (!line.configured) { - Not configured - } - {{ line.asOnDate }} - @if (line.configured) { - {{ amount(line.amount) }} - } @else { - — - } -
- {{ account.glCode }} - {{ account.name }} - - @if (account.excluded) { - excluded — {{ account.excluded }} - } - - @if (isPartial(account)) { - - {{ account.weightPercentage }}% of {{ amount(account.accountBalance) }} - - } - - @if (account.liabilityClass) { - {{ account.liabilityClass | titlecase }} - } - {{ amount(account.balance) }}
{{ data.rateType }} required ({{ data.ratePercentage }}% of NDTL){{ amount(data.requiredAmount) }}
{{ data.rateType }} maintained{{ amount(data.maintainedAmount) }}
{{ isShortfall ? 'Shortfall' : 'Surplus' }}{{ amount(shortfallAmount) }}
-
- - - @if (data.maintenance; as m) { -
-

- Maintenance over the fortnight - @if (!m.fortnightComplete) { - In progress — {{ m.dayCount }} days so far - } -

-

- CRR is reckoned over the whole fortnight, not on one day: the full requirement must be held - on average, and at least {{ m.dailyMinimumPercent }}% of it on every single day. -

- -
-
- Average maintained - {{ amount(m.average) }} +
+ NDTL base + {{ amount(data.ndtlAmount) }} - {{ m.averageMet ? 'Meets' : 'Below' }} required {{ amount(m.requiredAverage) }} + DTL {{ amount(data.dtlAmount) }} less {{ amount(data.interBankAssets) }} · as at + {{ data.basis.dtlAsOnDate }}
-
- Lowest day - {{ amount(m.lowest) }} - - on {{ m.lowestOn }} · floor {{ amount(m.dailyMinimumAmount) }} - +
+ {{ data.rateType }} rate + {{ data.ratePercentage }}% + @if (data.rateIsSeedDefault) { + Placeholder — not confirmed + } @else { + in force on {{ data.reportingDate }} + }
- Days counted - {{ m.dayCount }} - {{ m.fortnightComplete ? 'Full fortnight' : 'To reporting date' }} + Required + {{ amount(data.requiredAmount) }} + {{ data.ratePercentage }}% of NDTL +
+
+ Maintained + {{ amount(data.maintainedAmount) }} + {{ percent(data.maintainedPercentage) }} of NDTL +
+
+ {{ isShortfall ? 'Shortfall' : 'Surplus' }} + {{ amount(shortfallAmount) }} + {{ isShortfall ? 'Below requirement' : 'Above requirement' }}
+ } + +
- - - + + + - @for (day of m.days; track day.date) { - - - - + + + + + + @for (account of line.accounts; track account.glAccountId) { + + + + + + } + } + + @if (data.computable) { + + + + + + + + + + + + + + }
DateMaintained (INR)Against floorParticularsAs atAmount (INR)
{{ day.date }}{{ amount(day.amount) }} - @if (day.amount < m.dailyMinimumAmount) { - Below floor - } @else { - OK + + @for (line of data.lines; track $index) { +
+ {{ line.label }} + @if (!line.configured) { + Not configured } {{ line.asOnDate }} + @if (line.configured) { + {{ amount(line.amount) }} + } @else { + — + } +
+ {{ account.glCode }} + {{ account.name }} + + @if (account.excluded) { + excluded — {{ account.excluded }} + } + + @if (isPartial(account)) { + + {{ account.weightPercentage }}% of {{ amount(account.accountBalance) }} + + } + + @if (account.liabilityClass) { + {{ account.liabilityClass | titlecase }} + } + {{ amount(account.balance) }}
{{ data.rateType }} required ({{ data.ratePercentage }}% of NDTL){{ amount(data.requiredAmount) }}
{{ data.rateType }} maintained{{ amount(data.maintainedAmount) }}
{{ isShortfall ? 'Shortfall' : 'Surplus' }}{{ amount(shortfallAmount) }}
- } - - @for (section of namedSections; track section.title) { - @if (section.rows.length) { + + @if (data.maintenance; as m) {
-

{{ section.title }}

-

{{ section.hint }}

+

+ Daily maintenance — {{ m.periodLabel || 'this month' }} + @if (!m.fortnightComplete) { + In progress — {{ m.dayCount }} days so far + } +

+

+ Every day of the month, against the requirement in force. CRR is reckoned over the period rather than on one + day: the full requirement must be held on average, and at least + {{ m.dailyMinimumPercent }}% of it on every single day. +

+ +
+
+ Average maintained + {{ amount(m.average) }} + + {{ m.averageMet ? 'Meets' : 'Below' }} required {{ amount(m.requiredAverage) }} + +
+
+ Lowest day + {{ amount(m.lowest) }} + on {{ m.lowestOn }} · floor {{ amount(m.dailyMinimumAmount) }} +
+
+ Days counted + {{ m.dayCount }} + {{ m.fortnightComplete ? 'Full fortnight' : 'To reporting date' }} +
+
+ - - + + + + + - @for (row of section.rows; track row.configKey) { - - + @for (day of m.days; track day.date) { + + + + + @@ -311,6 +305,250 @@
ParticularsAmount (INR)DateRequired (INR)Actual (INR)Surplus / (Shortfall)Against floor
- {{ row.label }} - @if (!row.configured) { - Not configured - } -
{{ day.date }}{{ amount(day.required) }}{{ amount(day.amount) }}{{ amount(day.surplus) }} - @if (row.configured) { - {{ amount(row.amount) }} + @if (day.amount < m.dailyMinimumAmount) { + Below floor } @else { - — + OK }
} + + + @for (section of namedSections; track section.title) { + @if (section.rows.length) { +
+

{{ section.title }}

+

{{ section.hint }}

+ + + + + + + + + @for (row of section.rows; track row.configKey) { + + + + + } + +
ParticularsAmount (INR)
+ {{ row.label }} + @if (!row.configured) { + Not configured + } + + @if (row.configured) { + {{ amount(row.amount) }} + } @else { + — + } +
+
+ } + } + } + } + + + @if (activeTab === 'saved') { +
+

+ Saved returns + @if (savedReturns.length) { + {{ savedReturns.length }} + } +

+

+ Returns kept for the record. Each opens exactly as it was saved — the stored statement, not a re-run of today's + ledger, so a figure already reported cannot change underneath it. +

+ + @if (data?.computable) { +
+ + Note (optional) + + + +
+ } + + @if (savedLoading) { +
Loading saved returns…
+ } @else if (!savedReturns.length) { +
Nothing saved yet. Generate a return and press Save to keep it.
+ } @else { + + + + + + + + + + + + + + + @for (row of savedReturns; track row.id) { + + + + + + + + + + + } + +
Reporting datePeriodNDTLRequiredMaintainedStatusSaved
{{ row.reportingDate }}{{ row.periodStart }} → {{ row.periodEnd }}{{ amount(row.ndtlAmount) }}{{ amount(row.requiredAmount) }}{{ amount(row.maintainedAmount) }} + + + @for (s of filingStatuses; track s) { + {{ statusLabel(s) }} + } + + + {{ row.createdOn }}
{{ row.createdBy }}
+ + +
+ } +
+ + + @if (viewingSaved && savedData; as _) { +
+
+
+

+ Saved return — {{ savedData.reportingDate }} + {{ statusLabel(viewingSaved.status) }} +

+

+ Saved {{ viewingSaved.createdOn }} by {{ viewingSaved.createdBy || 'unknown' }}. These are the figures as + they stood when it was saved — not today's ledger, which may have moved since. + @if (viewingSaved.remarks) { + “{{ viewingSaved.remarks }}” + } +

+
+ +
+ +
+
+ NDTL base + {{ amount(savedData.ndtlAmount ?? savedData.dtlAmount) }} + as at {{ savedData.basis.dtlAsOnDate }} +
+
+ {{ savedData.rateType }} rate + {{ savedData.ratePercentage }}% + as filed +
+
+ Required + {{ amount(savedData.requiredAmount) }} + {{ savedData.ratePercentage }}% of NDTL +
+
+ Maintained + {{ amount(savedData.maintainedAmount) }} + on {{ savedData.basis.holdingsAsOnDate }} +
+
+ {{ savedData.compliant ? 'Surplus' : 'Shortfall' }} + {{ amount(savedSurplus) }} + {{ savedData.compliant ? 'Above requirement' : 'Below requirement' }} +
+
+ + + + + + + + + + + @for (line of savedData.lines; track line.configKey) { + + + + + + @for (account of line.accounts; track account.glAccountId) { + + + + + + } + } + +
ParticularsAs atAmount (INR)
+ {{ line.label }} + {{ line.asOnDate }} + @if (line.configured) { + {{ amount(line.amount) }} + } @else { + — + } +
+ {{ account.glCode }} + {{ account.name }} + @if (isPartial(account)) { + + {{ account.weightPercentage }}% of {{ amount(account.accountBalance) }} + + } + @if (account.liabilityClass) { + {{ account.liabilityClass | titlecase }} + } + {{ amount(account.balance) }}
+ + @if (savedData.maintenance; as sm) { +

Daily maintenance as filed — {{ sm.periodLabel || '' }}

+ + + + + + + + + + + @for (day of sm.days; track day.date) { + + + + + + + } + +
DateRequiredActualSurplus / (Shortfall)
{{ day.date }}{{ amount(day.required) }}{{ amount(day.amount) }}{{ amount(day.surplus) }}
+ } +
} } diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.component.scss b/src/app/rbi-circulars/reserve-return/reserve-return.component.scss index 6577a52db..cdc132990 100644 --- a/src/app/rbi-circulars/reserve-return/reserve-return.component.scss +++ b/src/app/rbi-circulars/reserve-return/reserve-return.component.scss @@ -129,6 +129,52 @@ $red: #c5221f; width: 180px; } +// ── Tabs ── + +.rr-tabs { + display: flex; + gap: 4px; + border-bottom: 1px solid $border; + margin-bottom: 16px; +} + +.rr-tab { + display: inline-flex; + align-items: center; + gap: 8px; + background: none; + border: none; + border-bottom: 2px solid transparent; + padding: 10px 16px; + font-size: 13px; + font-weight: 600; + color: $gray-500; + cursor: pointer; + font-family: inherit; + + i { + font-size: 12px; + } + + &:hover { + color: $gray-900; + } + + &--on { + color: $primary; + border-bottom-color: $primary; + } + + &__count { + font-size: 10.5px; + font-weight: 700; + background: #e8f0fe; + color: $primary; + border-radius: 20px; + padding: 1px 8px; + } +} + // ── Basis ── .rr-basis { @@ -297,7 +343,7 @@ $red: #c5221f; font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; - padding: 10px 10px; + padding: 10px; border-bottom: 1px solid $border; white-space: nowrap; } @@ -446,6 +492,154 @@ $red: #c5221f; font-weight: 600; } +// A shortfall on a day reads as one, rather than as a number with a minus sign in front. +.rr-neg { + color: $red; + font-weight: 600; +} + +// ── Saved returns ── + +.rr-empty { + padding: 22px; + text-align: center; + color: $gray-500; + font-size: 12.5px; +} + +.rr-save-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 16px; + + &__note { + flex: 1 1 340px; + } +} + +.rr-saved { + &__period, + &__meta { + font-size: 11px; + color: $gray-500; + white-space: nowrap; + } + + &__status { + width: 170px; + } + + &__actions { + white-space: nowrap; + } + + &__view { + font-size: 12px !important; + min-width: 0 !important; + color: $primary !important; + } + + &__del { + min-width: 0 !important; + padding: 0 8px !important; + color: $gray-500 !important; + + &:hover { + color: $red !important; + } + } + + &__remarks { + display: block; + margin-top: 4px; + color: $gray-500; + } + + &--open td { + background: #e8f0fe; + } +} + +// The opened snapshot, in its own section. Tinted and edged so it reads as a different document +// from the live return above it rather than as more of the same page. +.rr-card--saved { + border-color: #c6dafc; + background: #fbfdff; + border-left: 4px solid $primary; +} + +.rr-saved-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.rr-saved__subhead { + margin: 20px 0 8px; + font-size: 13px; + font-weight: 600; + color: $gray-900; +} + +// ── Print ── +// The browser's print dialog is also its save-as-PDF, so one stylesheet serves both. Controls, +// navigation and the saved-returns list are dropped: a printed return is a statement, and a page +// carrying dead buttons reads as a screenshot of software rather than as a filing. +@media print { + .rr-noprint, + .rr-header__actions, + .rr-unmet__link { + display: none !important; + } + + .rr-container { + max-width: none; + padding: 0; + } + + .rr-card, + .rr-basis, + .rr-stat { + border-color: #999 !important; + box-shadow: none !important; + break-inside: avoid; + } + + .rr-card { + overflow: visible !important; + } + + // Header rows repeat on every printed page, so a table spilling over a page break stays readable. + .rr-table thead { + display: table-header-group; + } + + .rr-table tr { + break-inside: avoid; + } + + .rr-toast { + display: none !important; + } + + // Backgrounds carry meaning here — a day below the floor is shaded, not merely labelled — so they + // must survive the print engine's default of dropping them to save ink. + .rr-container, + .rr-card, + .rr-basis, + .rr-stat, + .rr-table, + .rr-table th, + .rr-table td { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } +} + .rr-badge { display: inline-block; margin-left: 8px; @@ -479,7 +673,7 @@ $red: #c5221f; z-index: 1000; } -@media (max-width: 720px) { +@media (width <= 720px) { .rr-header__actions { width: 100%; } diff --git a/src/app/shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component.html b/src/app/shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component.html index e121e3be3..7084e2a16 100644 --- a/src/app/shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component.html +++ b/src/app/shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component.html @@ -21,7 +21,7 @@
@@ -72,7 +72,7 @@
@@ -81,7 +81,7 @@
@@ -143,7 +143,7 @@
diff --git a/src/app/shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component.ts b/src/app/shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component.ts index 769771faf..0b3948c00 100644 --- a/src/app/shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component.ts +++ b/src/app/shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component.ts @@ -26,6 +26,8 @@ export class ViewSavingsAccountingDetailsComponent { @Input() accountingRule: OptionData; @Input() accountingMappings: any[] = []; + /** Product name for GL row titles (e.g. 'Fixed deposit'); omitted = savings wording. */ + @Input() productLabel?: string; isCashOrAccrualAccounting(): boolean { if (this.accountingRule) { diff --git a/src/assets/translations/en-US.json b/src/assets/translations/en-US.json index 9160611c2..2ce8822a5 100644 --- a/src/assets/translations/en-US.json +++ b/src/assets/translations/en-US.json @@ -2236,6 +2236,9 @@ "Interest free period": "Interest free period", "Interest method": "Interest method", "Interest on savings": "Interest on savings", + "Fixed deposit control": "Fixed deposit control", + "Fixed deposit reference": "Fixed deposit reference", + "Interest on fixed deposits": "Interest on fixed deposits", "Interest rate": "Interest Rate", "Interest recalculation compounding on": "Interest recalculation compounding on", "Interest waived on": "Interest waived on", @@ -2793,6 +2796,7 @@ "Savings Product": "Savings Product", "Savings SubType": "Savings SubType", "Savings transfers in suspense": "Savings transfers in suspense", + "Transfers in suspense": "Transfers in suspense", "Schedule Date": "Schedule Date", "Share Details": "Share Details", "Scheduler Jobs": "Scheduler Jobs", diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 44f95bdbb..6a375a6e4 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260924', - 'hash': 'a6e62201' + 'version': '260928', + 'hash': '6cbb31b5' }, 'allow_switching_backend_instance': true };